From 8e577becc7f1014d5cf48148825d07942836aede Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 26 Apr 2018 12:09:48 +0200 Subject: [PATCH] MOBILE-2350 scorm: Implement SCORM player --- .../mod/scorm/components/components.module.ts | 10 +- .../components/toc-popover/toc-popover.html | 19 + .../components/toc-popover/toc-popover.scss | 3 + .../components/toc-popover/toc-popover.ts | 54 +++ src/addon/mod/scorm/pages/player/player.html | 19 + .../mod/scorm/pages/player/player.module.ts | 33 ++ src/addon/mod/scorm/pages/player/player.ts | 450 ++++++++++++++++++ src/addon/mod/scorm/providers/helper.ts | 213 ++++++++- src/components/iframe/iframe.ts | 7 + 9 files changed, 803 insertions(+), 5 deletions(-) create mode 100644 src/addon/mod/scorm/components/toc-popover/toc-popover.html create mode 100644 src/addon/mod/scorm/components/toc-popover/toc-popover.scss create mode 100644 src/addon/mod/scorm/components/toc-popover/toc-popover.ts create mode 100644 src/addon/mod/scorm/pages/player/player.html create mode 100644 src/addon/mod/scorm/pages/player/player.module.ts create mode 100644 src/addon/mod/scorm/pages/player/player.ts diff --git a/src/addon/mod/scorm/components/components.module.ts b/src/addon/mod/scorm/components/components.module.ts index 2a9c6569b..197d63ad1 100644 --- a/src/addon/mod/scorm/components/components.module.ts +++ b/src/addon/mod/scorm/components/components.module.ts @@ -20,10 +20,12 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModScormIndexComponent } from './index/index'; +import { AddonModScormTocPopoverComponent } from './toc-popover/toc-popover'; @NgModule({ declarations: [ - AddonModScormIndexComponent + AddonModScormIndexComponent, + AddonModScormTocPopoverComponent ], imports: [ CommonModule, @@ -36,10 +38,12 @@ import { AddonModScormIndexComponent } from './index/index'; providers: [ ], exports: [ - AddonModScormIndexComponent + AddonModScormIndexComponent, + AddonModScormTocPopoverComponent ], entryComponents: [ - AddonModScormIndexComponent + AddonModScormIndexComponent, + AddonModScormTocPopoverComponent ] }) export class AddonModScormComponentsModule {} diff --git a/src/addon/mod/scorm/components/toc-popover/toc-popover.html b/src/addon/mod/scorm/components/toc-popover/toc-popover.html new file mode 100644 index 000000000..0518f03f7 --- /dev/null +++ b/src/addon/mod/scorm/components/toc-popover/toc-popover.html @@ -0,0 +1,19 @@ + + +

{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}

+
+ +

{{ 'addon.mod_scorm.mod_scorm.browsemode' }}

+
+ +

{{ 'addon.mod_scorm.mod_scorm.reviewmode' }}

+
+ + + + + + {{ sco.title }} + + +
diff --git a/src/addon/mod/scorm/components/toc-popover/toc-popover.scss b/src/addon/mod/scorm/components/toc-popover/toc-popover.scss new file mode 100644 index 000000000..ed116481c --- /dev/null +++ b/src/addon/mod/scorm/components/toc-popover/toc-popover.scss @@ -0,0 +1,3 @@ +addon-mod-scorm-toc-popover { + +} diff --git a/src/addon/mod/scorm/components/toc-popover/toc-popover.ts b/src/addon/mod/scorm/components/toc-popover/toc-popover.ts new file mode 100644 index 000000000..aaa3aea88 --- /dev/null +++ b/src/addon/mod/scorm/components/toc-popover/toc-popover.ts @@ -0,0 +1,54 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { NavParams, ViewController } from 'ionic-angular'; +import { AddonModScormProvider } from '../../providers/scorm'; + +/** + * Component to display the TOC of a SCORM. + */ +@Component({ + selector: 'addon-mod-scorm-toc-popover', + templateUrl: 'toc-popover.html' +}) +export class AddonModScormTocPopoverComponent { + toc: any[]; + isBrowse: boolean; + isReview: boolean; + attemptToContinue: number; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.toc = navParams.get('toc') || []; + this.attemptToContinue = navParams.get('attemptToContinue'); + + const mode = navParams.get('mode'); + + this.isBrowse = mode === AddonModScormProvider.MODEBROWSE; + this.isReview = mode === AddonModScormProvider.MODEREVIEW; + } + + /** + * Function called when a SCO is clicked. + * + * @param {any} sco Clicked SCO. + */ + loadSco(sco: any): void { + if (!sco.prereq || !sco.isvisible || !sco.launch) { + return; + } + + this.viewCtrl.dismiss(sco); + } +} diff --git a/src/addon/mod/scorm/pages/player/player.html b/src/addon/mod/scorm/pages/player/player.html new file mode 100644 index 000000000..8ffa420a7 --- /dev/null +++ b/src/addon/mod/scorm/pages/player/player.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + +

{{ errorMessage | translate }}

+
+
diff --git a/src/addon/mod/scorm/pages/player/player.module.ts b/src/addon/mod/scorm/pages/player/player.module.ts new file mode 100644 index 000000000..dd5e85b1f --- /dev/null +++ b/src/addon/mod/scorm/pages/player/player.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModScormPlayerPage } from './player'; + +@NgModule({ + declarations: [ + AddonModScormPlayerPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModScormPlayerPage), + TranslateModule.forChild() + ], +}) +export class AddonModScormPlayerPageModule {} diff --git a/src/addon/mod/scorm/pages/player/player.ts b/src/addon/mod/scorm/pages/player/player.ts new file mode 100644 index 000000000..37f9f8110 --- /dev/null +++ b/src/addon/mod/scorm/pages/player/player.ts @@ -0,0 +1,450 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { IonicPage, NavParams, PopoverController } from 'ionic-angular'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm'; +import { AddonModScormHelperProvider } from '../../providers/helper'; +import { AddonModScormSyncProvider } from '../../providers/scorm-sync'; +import { AddonModScormDataModel12 } from '../../classes/data-model-12'; +import { AddonModScormTocPopoverComponent } from '../../components/toc-popover/toc-popover'; + +/** + * Page that allows playing a SCORM. + */ +@IonicPage({ segment: 'addon-mod-scorm-player' }) +@Component({ + selector: 'page-addon-mod-scorm-player', + templateUrl: 'player.html', +}) +export class AddonModScormPlayerPage implements OnInit, OnDestroy { + + title: string; // Title. + scorm: any; // The SCORM object. + showToc: boolean; // Whether to show the table of contents (TOC). + loadingToc = true; // Whether the TOC is being loaded. + toc: any[]; // List of SCOs. + loaded: boolean; // Whether the data has been loaded. + previousSco: any; // Previous SCO. + nextSco: any; // Next SCO. + src: string; // Iframe src. + errorMessage: string; // Error message. + + protected siteId: string; + protected mode: string; // Mode to play the SCORM. + protected newAttempt: boolean; // Whether to start a new attempt. + protected organizationId: string; // Organization ID to load. + protected attempt: number; // The attempt number. + protected offline = false; // Whether it's offline mode. + protected userData: any; // User data. + protected initialScoId: number; // Initial SCO ID to load. + protected currentSco: any; // Current SCO. + protected dataModel: AddonModScormDataModel12; // Data Model. + protected attemptToContinue: number; // Attempt to continue (for the popover). + + // Observers. + protected tocObserver: any; + protected launchNextObserver: any; + protected launchPrevObserver: any; + protected goOfflineObserver: any; + + constructor(navParams: NavParams, protected popoverCtrl: PopoverController, protected eventsProvider: CoreEventsProvider, + protected sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, + protected domUtils: CoreDomUtilsProvider, protected timeUtils: CoreTimeUtilsProvider, + protected scormProvider: AddonModScormProvider, protected scormHelper: AddonModScormHelperProvider, + protected scormSyncProvider: AddonModScormSyncProvider) { + + this.scorm = navParams.get('scorm') || {}; + this.mode = navParams.get('mode') || AddonModScormProvider.MODENORMAL; + this.newAttempt = !!navParams.get('newAttempt'); + this.organizationId = navParams.get('organizationId'); + this.initialScoId = navParams.get('scoId'); + this.siteId = this.sitesProvider.getCurrentSiteId(); + + // We use SCORM name at start, later we'll use the SCO title. + this.title = this.scorm.name; + + // Block the SCORM so it cannot be synchronized. + this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + + this.showToc = this.scormProvider.displayTocInPlayer(this.scorm); + + if (this.scorm.popup) { + // If we receive a value <= 100 we need to assume it's a percentage. + if (this.scorm.width <= 100) { + this.scorm.width = this.scorm.width + '%'; + } + if (this.scorm.height <= 100) { + this.scorm.height = this.scorm.height + '%'; + } + } + + // Fetch the SCORM data. + this.fetchData().then(() => { + if (this.currentSco) { + // Set start time if it's a new attempt. + const promise = this.newAttempt ? this.setStartTime(this.currentSco.id) : Promise.resolve(); + + return promise.catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + }).finally(() => { + // Load SCO. + this.loadSco(this.currentSco); + }); + } + }).finally(() => { + this.loaded = true; + }); + + // Listen for events to update the TOC, navigate through SCOs and go offline. + this.tocObserver = this.eventsProvider.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => { + if (data.scormId === this.scorm.id) { + if (this.offline) { + // Wait a bit to make sure data is stored. + setTimeout(this.refreshToc.bind(this), 100); + } else { + this.refreshToc(); + } + } + }, this.siteId); + + this.launchNextObserver = this.eventsProvider.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => { + if (data.scormId === this.scorm.id && this.nextSco) { + this.loadSco(this.nextSco); + } + }, this.siteId); + + this.launchPrevObserver = this.eventsProvider.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => { + if (data.scormId === this.scorm.id && this.previousSco) { + this.loadSco(this.previousSco); + } + }, this.siteId); + + this.goOfflineObserver = this.eventsProvider.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => { + if (data.scormId === this.scorm.id && !this.offline) { + this.offline = true; + + // Wait a bit to prevent collisions between this store and SCORM API's store. + setTimeout(() => { + this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }).then(() => { + this.refreshToc(); + }); + }, 200); + } + }, this.siteId); + } + + /** + * Calculate the next and previous SCO. + * + * @param {number} scoId Current SCO ID. + */ + protected calculateNextAndPreviousSco(scoId: number): void { + this.previousSco = this.scormHelper.getPreviousScoFromToc(this.toc, scoId); + this.nextSco = this.scormHelper.getNextScoFromToc(this.toc, scoId); + } + + /** + * Determine the attempt to use, the mode (normal/preview) and if it's offline or online. + * + * @param {AddonModScormAttemptCountResult} attemptsData Attempts count. + * @return {Promise} Promise resolved when done. + */ + protected determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise { + let result; + + return this.scormHelper.determineAttemptToContinue(this.scorm, attemptsData).then((data) => { + this.attempt = data.number; + this.offline = data.offline; + + if (this.attempt != attemptsData.lastAttempt.number) { + this.attemptToContinue = this.attempt; + } + + // Check if current attempt is incomplete. + if (this.attempt > 0) { + return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline); + } else { + // User doesn't have attempts. Last attempt is not incomplete (since he doesn't have any). + return false; + } + }).then((incomplete) => { + // Determine mode and attempt to use. + result = this.scormProvider.determineAttemptAndMode(this.scorm, this.mode, this.attempt, this.newAttempt, incomplete); + + if (result.attempt > this.attempt) { + // We're creating a new attempt. + if (this.offline) { + // Last attempt was offline, so we'll create a new offline attempt. + return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); + } else { + // Last attempt was online, verify that we can create a new online attempt. We ignore cache. + return this.scormProvider.getScormUserData(this.scorm.id, result.attempt, undefined, false, true).catch(() => { + // Cannot communicate with the server, create an offline attempt. + this.offline = true; + + return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); + }); + } + } + }).then(() => { + this.mode = result.mode; + this.newAttempt = result.newAttempt; + this.attempt = result.attempt; + }); + } + + /** + * Fetch data needed to play the SCORM. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + // Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played. + return this.scormSyncProvider.waitForSync(this.scorm.id).then(() => { + // Get attempts data. + return this.scormProvider.getAttemptCount(this.scorm.id).then((attemptsData) => { + return this.determineAttemptAndMode(attemptsData).then(() => { + // Fetch TOC and get user data. + const promises = []; + + promises.push(this.fetchToc()); + promises.push(this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, this.offline) + .then((data) => { + this.userData = data; + })); + + return Promise.all(promises); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + }); + }); + } + + /** + * Fetch the TOC. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchToc(): Promise { + this.loadingToc = true; + + // We need to check incomplete again: attempt number or status might have changed. + return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline).then((incomplete) => { + this.scorm.incomplete = incomplete; + + // Get TOC. + return this.scormProvider.getOrganizationToc(this.scorm.id, this.attempt, this.organizationId, this.offline); + }).then((toc) => { + this.toc = this.scormProvider.formatTocToArray(toc); + + // Get images for each SCO. + this.toc.forEach((sco) => { + sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete); + }); + + // Determine current SCO if we received an ID.. + if (this.initialScoId > 0) { + // SCO set by parameter, get it from TOC. + this.currentSco = this.scormHelper.getScoFromToc(this.toc, this.initialScoId); + } + + if (!this.currentSco) { + // No SCO defined. Get the first valid one. + return this.scormHelper.getFirstSco(this.scorm.id, this.attempt, this.toc, this.organizationId, this.offline) + .then((sco) => { + + if (sco) { + this.currentSco = sco; + } else { + // We couldn't find a SCO to load: they're all inactive or without launch URL. + this.errorMessage = 'addon.mod_scorm.errornovalidsco'; + } + }); + } + }).finally(() => { + this.loadingToc = false; + }); + } + + /** + * Page will leave. + */ + ionViewWillLeave(): void { + // Empty src when leaving the state so unload event is triggered in the iframe. + this.src = ''; + } + + /** + * Load a SCO. + * + * @param {any} sco The SCO to load. + */ + protected loadSco(sco: any): void { + if (!this.dataModel) { + // Create the model. + this.dataModel = new AddonModScormDataModel12(this.eventsProvider, this.scormProvider, this.siteId, this.scorm, sco.id, + this.attempt, this.userData, this.mode, this.offline); + + // Add the model to the window so the SCORM can access it. + ( window).API = this.dataModel; + } else { + // Load the SCO in the existing model. + this.dataModel.loadSco(sco.id); + } + + this.currentSco = sco; + this.title = sco.title || this.scorm.name; // Try to use SCO title. + + this.calculateNextAndPreviousSco(sco.id); + + // Load the SCO source. + this.scormProvider.getScoSrc(this.scorm, sco).then((src) => { + if (src == this.src) { + // Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed. + this.src = ''; + + setTimeout(() => { + this.src = src; + }); + } else { + this.src = src; + } + }); + + if (sco.scormtype == 'asset') { + // Mark the asset as completed. + const tracks = [{ + element: 'cmi.core.lesson_status', + value: 'completed' + }]; + + this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, this.offline).catch(() => { + // Error saving data. We'll go offline if we're online and the asset is not marked as completed already. + if (!this.offline) { + return this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, false).then((data) => { + if (!data[sco.id] || data[sco.id].userdata['cmi.core.lesson_status'] != 'completed') { + // Go offline. + return this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).then(() => { + this.offline = true; + this.dataModel.setOffline(true); + + return this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, true); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }); + } + }); + } + }).then(() => { + // Refresh TOC, some prerequisites might have changed. + this.refreshToc(); + }); + } + + // Trigger SCO launch event. + this.scormProvider.logLaunchSco(this.scorm.id, sco.id).catch(() => { + // Ignore errors. + }); + } + + /** + * Show the TOC. + * + * @param {MouseEvent} event Event. + */ + openToc(event: MouseEvent): void { + const popover = this.popoverCtrl.create(AddonModScormTocPopoverComponent, { + toc: this.toc, + attemptToContinue: this.attemptToContinue, + mode: this.mode + }); + + // If the popover sends back a SCO, load it. + popover.onDidDismiss((sco) => { + if (sco) { + this.loadSco(sco); + } + }); + + popover.present({ + ev: event + }); + } + + /** + * Refresh the TOC. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshToc(): Promise { + return this.scormProvider.invalidateAllScormData(this.scorm.id).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchToc(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + }); + } + + /** + * Set SCORM start time. + * + * @param {number} scoId SCO ID. + * @return {Promise} Promise resolved when done. + */ + protected setStartTime(scoId: number): Promise { + const tracks = [{ + element: 'x.start.time', + value: this.timeUtils.timestamp() + }]; + + return this.scormProvider.saveTracks(scoId, this.attempt, tracks, this.scorm, this.offline).then(() => { + if (!this.offline) { + // New online attempt created, update cached data about online attempts. + this.scormProvider.getAttemptCount(this.scorm.id, false, true).catch(() => { + // Ignore errors. + }); + } + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Stop listening for events. + this.tocObserver && this.tocObserver.off(); + this.launchNextObserver && this.launchNextObserver.off(); + this.launchPrevObserver && this.launchPrevObserver.off(); + this.goOfflineObserver && this.goOfflineObserver.off(); + + // Unblock the SCORM so it can be synced. + this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); + } +} diff --git a/src/addon/mod/scorm/providers/helper.ts b/src/addon/mod/scorm/providers/helper.ts index 5c0849fee..721148e85 100644 --- a/src/addon/mod/scorm/providers/helper.ts +++ b/src/addon/mod/scorm/providers/helper.ts @@ -13,8 +13,12 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'; +import { AddonModScormOfflineProvider } from './scorm-offline'; /** * Helper service that provides some features for SCORM. @@ -22,9 +26,13 @@ import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm' @Injectable() export class AddonModScormHelperProvider { - protected div = document.createElement('div'); // A div element to search in HTML code. + // List of elements we want to ignore when copying attempts (they're calculated). + protected elementsToIgnore = ['status', 'score_raw', 'total_time', 'session_time', 'student_id', 'student_name', 'credit', + 'mode', 'entry']; - constructor(private domUtils: CoreDomUtilsProvider, private scormProvider: AddonModScormProvider) { } + constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService, + private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, + private scormProvider: AddonModScormProvider, private scormOfflineProvider: AddonModScormOfflineProvider) { } /** * Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it. @@ -58,6 +66,93 @@ export class AddonModScormHelperProvider { }); } + /** + * Creates a new offline attempt based on an existing online attempt. + * + * @param {any} scorm SCORM. + * @param {number} attempt Number of the online attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the attempt is created. + */ + convertAttemptToOffline(scorm: any, attempt: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get data from the online attempt. + return this.scormProvider.getScormUserData(scorm.id, attempt, undefined, false, false, siteId).then((onlineData) => { + // The SCORM API might have written some data to the offline attempt already. + // We don't want to override it with cached online data. + return this.scormOfflineProvider.getScormUserData(scorm.id, attempt, undefined, siteId).catch(() => { + // Ignore errors. + }).then((offlineData) => { + const dataToStore = this.utils.clone(onlineData); + + // Filter the data to copy. + for (const scoId in dataToStore) { + const sco = dataToStore[scoId]; + + // Delete calculated data. + this.elementsToIgnore.forEach((el) => { + delete sco.userdata[el]; + }); + + // Don't override offline data. + if (offlineData && offlineData[sco.scoid] && offlineData[sco.scoid].userdata) { + const scoUserData = {}; + + for (const element in sco.userdata) { + if (!offlineData[sco.scoid].userdata[element]) { + // This element is not stored in offline, we can save it. + scoUserData[element] = sco.userdata[element]; + } + } + + sco.userdata = scoUserData; + } + } + + return this.scormOfflineProvider.createNewAttempt(scorm, attempt, dataToStore, onlineData, siteId); + }); + }).catch(() => { + // Shouldn't happen. + return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt')); + }); + } + + /** + * Creates a new offline attempt. + * + * @param {any} scorm SCORM. + * @param {number} newAttempt Number of the new attempt. + * @param {number} lastOnline Number of the last online attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the attempt is created. + */ + createOfflineAttempt(scorm: any, newAttempt: number, lastOnline: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Try to get data from online attempts. + return this.searchOnlineAttemptUserData(scorm.id, lastOnline, siteId).then((userData) => { + // We're creating a new attempt, remove all the user data that is not needed for a new attempt. + for (const scoId in userData) { + const sco = userData[scoId], + filtered = {}; + + for (const element in sco.userdata) { + if (element.indexOf('.') == -1 && this.elementsToIgnore.indexOf(element) == -1) { + // The element doesn't use a dot notation, probably SCO data. + filtered[element] = sco.userdata[element]; + } + } + + sco.userdata = filtered; + } + + return this.scormOfflineProvider.createNewAttempt(scorm, newAttempt, userData, undefined, siteId); + }).catch(() => { + return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt')); + }); + } + /** * Determines the attempt to continue/review. It will be: * - The last incomplete online attempt if it hasn't been continued in offline and all offline attempts are complete. @@ -97,6 +192,41 @@ export class AddonModScormHelperProvider { } } + /** + * Get the first SCO to load in a SCORM. If a non-empty TOC is provided, it will be the first valid SCO in the TOC. + * Otherwise, it will be the first valid SCO returned by $mmaModScorm#getScos. + * + * @param {number} scormId Scorm ID. + * @param {number} attempt Attempt number. + * @param {any[]} [toc] SCORM's TOC. + * @param {string} [organization] Organization to use. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the first SCO. + */ + getFirstSco(scormId: number, attempt: number, toc?: any[], organization?: string, offline?: boolean, siteId?: string) + : Promise { + + let promise; + if (toc && toc.length) { + promise = Promise.resolve(toc); + } else { + // SCORM doesn't have a TOC. Get all the scos. + promise = this.scormProvider.getScosWithData(scormId, attempt, organization, offline, false, siteId); + } + + return promise.then((scos) => { + // Search the first valid SCO. + for (let i = 0; i < scos.length; i++) { + const sco = scos[i]; + + if (sco.isvisible && sco.prereq && sco.launch) { + return sco; + } + } + }); + } + /** * Get the last attempt (number and whether it's offline). * It'll be the highest number as long as it doesn't surpass the max number of attempts. @@ -118,4 +248,83 @@ export class AddonModScormHelperProvider { }; } } + + /** + * Given a TOC in array format and a scoId, return the next available SCO. + * + * @param {any[]} toc SCORM's TOC. + * @param {number} scoId SCO ID. + * @return {any} Next SCO. + */ + getNextScoFromToc(toc: any, scoId: number): any { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id == scoId) { + // We found the current SCO. Now let's search the next visible SCO with fulfilled prerequisites. + for (let j = i + 1; j < toc.length; j++) { + if (toc[j].isvisible && toc[j].prereq && toc[j].launch) { + return toc[j]; + } + } + break; + } + } + } + + /** + * Given a TOC in array format and a scoId, return the previous available SCO. + * + * @param {any[]} toc SCORM's TOC. + * @param {number} scoId SCO ID. + * @return {any} Previous SCO. + */ + getPreviousScoFromToc(toc: any, scoId: number): any { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id == scoId) { + // We found the current SCO. Now let's search the previous visible SCO with fulfilled prerequisites. + for (let j = i - 1; j >= 0; j--) { + if (toc[j].isvisible && toc[j].prereq && toc[j].launch) { + return toc[j]; + } + } + break; + } + } + } + + /** + * Given a TOC in array format and a scoId, return the SCO. + * + * @param {any[]} toc SCORM's TOC. + * @param {number} scoId SCO ID. + * @return {any} SCO. + */ + getScoFromToc(toc: any[], scoId: number): any { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id == scoId) { + return toc[i]; + } + } + } + + /** + * Searches user data for an online attempt. If the data can't be retrieved, re-try with the previous online attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Online attempt to get the data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with user data. + */ + searchOnlineAttemptUserData(scormId: number, attempt: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, false, siteId).catch(() => { + if (attempt > 0) { + // We couldn't retrieve the data. Try again with the previous online attempt. + return this.searchOnlineAttemptUserData(scormId, attempt - 1, siteId); + } else { + // No more attempts to try. Reject + return Promise.reject(null); + } + }); + } } diff --git a/src/components/iframe/iframe.ts b/src/components/iframe/iframe.ts index 1f50b217e..f86bb4644 100644 --- a/src/components/iframe/iframe.ts +++ b/src/components/iframe/iframe.ts @@ -139,6 +139,13 @@ export class CoreIframeComponent implements OnInit, OnChanges { winAndDoc = this.getContentWindowAndDocument(element); this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document); this.treatLinks(element, winAndDoc.document); + + if (winAndDoc.window) { + // Send a resize events to the iframe so it calculates the right size if needed. + setTimeout(() => { + winAndDoc.window.dispatchEvent(new Event('resize')); + }, 1000); + } }); } }