From 68eb444bac3926b69da56cb98061b142d3664fe6 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 3 Aug 2020 11:58:53 +0100 Subject: [PATCH] issue/2805: Allowed component trackingIds and reworked to ES6 (#197) --- js/adapt-contrib-spoor.js | 150 +-- js/adapt-offlineStorage-scorm.js | 282 +++--- js/adapt-stateful-session.js | 389 ++++---- js/scorm.js | 11 - js/scorm/logger.js | 134 +-- js/scorm/wrapper.js | 940 +++++++++--------- js/serializers/ComponentSerializer.js | 143 +++ ...dDataSerializer.js => SCORMSuspendData.js} | 8 +- js/serializers/default.js | 95 -- js/serializers/questions.js | 215 ---- 10 files changed, 1083 insertions(+), 1284 deletions(-) delete mode 100644 js/scorm.js create mode 100644 js/serializers/ComponentSerializer.js rename js/serializers/{scormSuspendDataSerializer.js => SCORMSuspendData.js} (99%) delete mode 100644 js/serializers/default.js delete mode 100644 js/serializers/questions.js diff --git a/js/adapt-contrib-spoor.js b/js/adapt-contrib-spoor.js index 8484576b..ae1af59e 100644 --- a/js/adapt-contrib-spoor.js +++ b/js/adapt-contrib-spoor.js @@ -1,148 +1,48 @@ define([ 'core/js/adapt', - './scorm', + './scorm/wrapper', './adapt-stateful-session', './adapt-offlineStorage-scorm' -], function(Adapt, scorm, adaptStatefulSession) { +], function(Adapt, ScormWrapper, StatefulSession, OfflineStorage) { - // SCORM session manager + class Spoor extends Backbone.Controller { - var Spoor = _.extend({ - - _config: null, - - // Session Begin - - initialize: function() { - this.listenToOnce(Adapt, { - 'offlineStorage:prepare': this.onPrepareOfflineStorage, - 'app:dataReady': function() { - Adapt.wait.for(adaptStatefulSession.initialize.bind(adaptStatefulSession)); - } - }); - }, + initialize() { + this.config = null; + this.scorm = ScormWrapper.getInstance(); + this.listenToOnce(Adapt, 'offlineStorage:prepare', this._prepare); + } - onPrepareOfflineStorage: function() { - if (!this.checkConfig()) { + _prepare() { + this.config = Adapt.config.get('_spoor'); + if (!this.isEnabled) { Adapt.offlineStorage.setReadyStatus(); return; } - - this.configureAdvancedSettings(); - - scorm.initialize(); - - /* - force offlineStorage-scorm to initialise suspendDataStore - this allows us to do things like store the user's - chosen language before the rest of the course data loads - */ + this.statefulSession = new StatefulSession(); + this.offlineStorage = new OfflineStorage(this.statefulSession); + // force offlineStorage-scorm to initialise suspendDataStore - this allows + // us to do things like store the user's chosen language before the rest + // of the course data loads Adapt.offlineStorage.get(); - Adapt.offlineStorage.setReadyStatus(); - - this.setupEventListeners(); - }, - - checkConfig: function() { - this._config = Adapt.config.get('_spoor') || false; - - if (this._config && this._config._isEnabled !== false) return true; - - return false; - }, - - configureAdvancedSettings: function() { - if (this._config._advancedSettings) { - var settings = this._config._advancedSettings; - - if (settings._showDebugWindow) scorm.showDebugWindow(); - - scorm.setVersion(settings._scormVersion || "1.2"); - - if (settings._suppressErrors) { - scorm.suppressErrors = settings._suppressErrors; - } - - if (settings._commitOnStatusChange) { - scorm.commitOnStatusChange = settings._commitOnStatusChange; - } - - if (_.isFinite(settings._timedCommitFrequency)) { - scorm.timedCommitFrequency = settings._timedCommitFrequency; - } - - if (_.isFinite(settings._maxCommitRetries)) { - scorm.maxCommitRetries = settings._maxCommitRetries; - } - - if (_.isFinite(settings._commitRetryDelay)) { - scorm.commitRetryDelay = settings._commitRetryDelay; - } - - if ("_exitStateIfIncomplete" in settings) { - scorm.exitStateIfIncomplete = settings._exitStateIfIncomplete; - } - - if ("_exitStateIfComplete" in settings) { - scorm.exitStateIfComplete = settings._exitStateIfComplete; - } - } else { - /** - * force use of SCORM 1.2 by default - some LMSes (SABA/Kallidus for instance) present both APIs to the SCO and, if given the choice, - * the pipwerks code will automatically select the SCORM 2004 API - which can lead to unexpected behaviour. - */ - scorm.setVersion("1.2"); - } - - /** - * suppress SCORM errors if 'nolmserrors' is found in the querystring - */ - if(window.location.search.indexOf('nolmserrors') != -1) scorm.suppressErrors = true; - }, - - setupEventListeners: function() { - var advancedSettings = this._config._advancedSettings; - var shouldCommitOnVisibilityChange = (!advancedSettings || - advancedSettings._commitOnVisibilityChangeHidden !== false) && - document.addEventListener; - - this._onWindowUnload = this.onWindowUnload.bind(this); - $(window).on('beforeunload unload', this._onWindowUnload); - - if (shouldCommitOnVisibilityChange) { - document.addEventListener("visibilitychange", this.onVisibilityChange); - } - + // setup debug window keyboard shortcut require(['libraries/jquery.keycombo'], function() { // listen for user holding 'd', 'e', 'v' keys together $.onKeyCombo([68, 69, 86], function() { - scorm.showDebugWindow(); + Adapt.spoor.scorm.showDebugWindow(); }); }); - }, - - removeEventListeners: function() { - $(window).off('beforeunload unload', this._onWindowUnload); - - document.removeEventListener("visibilitychange", this.onVisibilityChange); - }, - - onVisibilityChange: function() { - if (document.visibilityState === "hidden") scorm.commit(); - }, - - // Session End - - onWindowUnload: function() { - this.removeEventListeners(); + } - if (!scorm.finishCalled){ - scorm.finish(); - } + get isEnabled() { + return (this.config && this.config._isEnabled); } - }, Backbone.Events); + } + + Adapt.spoor = new Spoor(); - Spoor.initialize(); + return Spoor; }); diff --git a/js/adapt-offlineStorage-scorm.js b/js/adapt-offlineStorage-scorm.js index 36eb0214..12b2c4f6 100644 --- a/js/adapt-offlineStorage-scorm.js +++ b/js/adapt-offlineStorage-scorm.js @@ -1,191 +1,237 @@ define([ 'core/js/adapt', - './scorm', - 'core/js/offlineStorage', - './adapt-stateful-session' -], function(Adapt, scorm, AdaptStatefulSession) { - - //SCORM handler for Adapt.offlineStorage interface. - - //Stores to help handle posting and offline uniformity - var temporaryStore = {}; - var suspendDataStore = {}; - var suspendDataRestored = false; - - Adapt.offlineStorage.initialize({ + './scorm/wrapper', + './serializers/SCORMSuspendData', + 'core/js/offlineStorage' +], function(Adapt, ScormWrapper, SCORMSuspendData) { + + /** + * SCORM handler for Adapt.offlineStorage interface. + */ + class OfflineStorage extends Backbone.Controller { + + initialize(statefulSession) { + this.scorm = ScormWrapper.getInstance(); + this.statefulSession = statefulSession; + this.temporaryStore = {}; + this.suspendDataStore = {}; + this.suspendDataRestored = false; + Adapt.offlineStorage.initialize(this); + } - save: function() { - AdaptStatefulSession.saveSessionState(); - }, + save() { + this.statefulSession.saveSessionState(); + } - serialize: SCORMSuspendData.serialize.bind(SCORMSuspendData), + serialize(...args) { + return SCORMSuspendData.serialize(...args); + } - deserialize: SCORMSuspendData.deserialize.bind(SCORMSuspendData), + deserialize(...args) { + return SCORMSuspendData.deserialize(...args); + } - get: function(name) { + get(name) { if (name === undefined) { - //If not connected return just temporary store. - if (this.useTemporaryStore()) return temporaryStore; + // If not connected return just temporary store. + if (this.useTemporaryStore()) return this.temporaryStore; - //Get all values as a combined object - suspendDataStore = this.getCustomStates(); + // Get all values as a combined object + this.suspendDataStore = this.getCustomStates(); - var data = _.extend(_.clone(suspendDataStore), { - location: scorm.getLessonLocation(), - score: scorm.getScore(), - status: scorm.getStatus(), - student: scorm.getStudentName(), + const data = Object.assign(_.clone(this.suspendDataStore), { + location: this.scorm.getLessonLocation(), + score: this.scorm.getScore(), + status: this.scorm.getStatus(), + student: this.scorm.getStudentName(), learnerInfo: this.getLearnerInfo() }); - suspendDataRestored = true; + this.suspendDataRestored = true; return data; } - //If not connected return just temporary store value. - if (this.useTemporaryStore()) return temporaryStore[name]; + // If not connected return just temporary store value. + if (this.useTemporaryStore()) return this.temporaryStore[name]; - //Get by name + // Get by name let courseState; switch (name.toLowerCase()) { - case "location": - return scorm.getLessonLocation(); - case "score": - return scorm.getScore(); - case "status": - return scorm.getStatus(); - case "student":// for backwards-compatibility. learnerInfo is preferred now and will give you more information - return scorm.getStudentName(); - case "learnerinfo": + case 'location': + return this.scorm.getLessonLocation(); + case 'score': + return this.scorm.getScore(); + case 'status': + return this.scorm.getStatus(); + case 'student': + // for backwards-compatibility. learnerInfo is preferred now and will + // give you more information + return this.scorm.getStudentName(); + case 'learnerinfo': return this.getLearnerInfo(); - case "coursestate": + case 'coursestate': courseState = this.getCustomState('c'); - const stateArray = courseState && SCORMSuspendData.deserialize(courseState) || []; + const stateArray = (courseState && SCORMSuspendData.deserialize(courseState)) || []; return { _isCourseComplete: Boolean(stateArray.slice(0, 1).map(Number)[0]), _isAssessmentPassed: Boolean(stateArray.slice(1, 2).map(Number)[0]), completion: stateArray.slice(2).map(Number).map(String).join('') || '' }; - case "completion": + case 'completion': courseState = this.getCustomState('c'); - return courseState && SCORMSuspendData.deserialize(courseState).slice(2).map(Number).map(String).join('') || ''; - case "_iscoursecomplete": + return (courseState && SCORMSuspendData + .deserialize(courseState) + .slice(2) + .map(Number) + .map(String) + .join('')) || ''; + case '_iscoursecomplete': courseState = this.getCustomState('c'); - return Boolean(courseState && SCORMSuspendData.deserialize(courseState).slice(0, 1).map(Number)[0]); - case "_isassessmentpassed": + return Boolean(courseState && SCORMSuspendData + .deserialize(courseState) + .slice(0, 1) + .map(Number)[0]); + case '_isassessmentpassed': courseState = this.getCustomState('c'); - return Boolean(courseState && SCORMSuspendData.deserialize(courseState).slice(1, 2).map(Number)[0]); - case "questions": + return Boolean(courseState && SCORMSuspendData + .deserialize(courseState) + .slice(1, 2) + .map(Number)[0]); + case 'questions': const questionsState = this.getCustomState('q'); return questionsState || ''; default: return this.getCustomState(name); } - }, + } - set: function(name, value) { - //Convert arguments to array and drop the 'name' parameter - var args = [].slice.call(arguments, 1); - var isObject = typeof name == "object"; + set(name, value) { + // Convert arguments to array and drop the 'name' parameter + const args = [...arguments].slice(1); + const isObject = typeof name === 'object'; if (isObject) { value = name; - name = "suspendData"; + name = 'suspendData'; } if (this.useTemporaryStore()) { if (isObject) { - temporaryStore = _.extend(temporaryStore, value); + Object.assign(this.temporaryStore, value); } else { - temporaryStore[name] = value; + this.temporaryStore[name] = value; } return true; } switch (name.toLowerCase()) { - case "interaction": - return scorm.recordInteraction.apply(scorm, args); - case "location": - return scorm.setLessonLocation.apply(scorm, args); - case "score": - return scorm.setScore.apply(scorm, args); - case "status": - return scorm.setStatus.apply(scorm, args); - case "student": - case "learnerinfo": + case 'interaction': + return this.scorm.recordInteraction(...args); + case 'location': + return this.scorm.setLessonLocation(...args); + case 'score': + return this.scorm.setScore(...args); + case 'status': + return this.scorm.setStatus(...args); + case 'student': + case 'learnerinfo': return false;// these properties are read-only - case "lang": - scorm.setLanguage(value); + case 'lang': + this.scorm.setLanguage(value); // fall-through so that lang gets stored in suspend_data as well: - // because in SCORM 1.2 cmi.student_preference.language is an optional data element - // so we can't rely on the LMS having support for it. - // If it does support it we may as well save the user's choice there purely for reporting purposes - case "suspenddata": - default: - if (isObject) { - suspendDataStore = _.extend(suspendDataStore, value); - } else { - suspendDataStore[name] = value; - } - - var dataAsString = JSON.stringify(suspendDataStore); - return (suspendDataRestored) ? scorm.setSuspendData(dataAsString) : false; + // because in SCORM 1.2 cmi.student_preference.language is an optional + // data element so we can't rely on the LMS having support for it. + // If it does support it we may as well save the user's choice there + // purely for reporting purposes + break; + case 'suspenddata': + break; } - }, - getCustomStates: function() { - var isSuspendDataStoreEmpty = _.isEmpty(suspendDataStore); - if (!isSuspendDataStoreEmpty && suspendDataRestored) return _.clone(suspendDataStore); + if (isObject) { + Object.assign(this.suspendDataStore, value); + } else { + this.suspendDataStore[name] = value; + } - var dataAsString = scorm.getSuspendData(); - if (dataAsString === "" || dataAsString === " " || dataAsString === undefined) return {}; + const dataAsString = JSON.stringify(this.suspendDataStore); + return (this.suspendDataRestored) ? this.scorm.setSuspendData(dataAsString) : false; + } + + getCustomStates() { + const isSuspendDataStoreEmpty = _.isEmpty(this.suspendDataStore); + if (!isSuspendDataStoreEmpty && this.suspendDataRestored) { + return _.clone(this.suspendDataStore); + } - var dataAsJSON = JSON.parse(dataAsString); - if (!isSuspendDataStoreEmpty && !suspendDataRestored) dataAsJSON = _.extend(dataAsJSON, suspendDataStore); + const dataAsString = this.scorm.getSuspendData(); + if (dataAsString === '' || dataAsString === ' ' || dataAsString === undefined) { + return {}; + } + + let dataAsJSON = JSON.parse(dataAsString); + if (!isSuspendDataStoreEmpty && !this.suspendDataRestored) { + Object.assign(dataAsJSON, this.suspendDataStore); + } return dataAsJSON; - }, + } - getCustomState: function(name) { - var dataAsJSON = this.getCustomStates(); + getCustomState(name) { + const dataAsJSON = this.getCustomStates(); return dataAsJSON[name]; - }, + } - useTemporaryStore: function() { - var cfg = Adapt.config.get('_spoor'); + useTemporaryStore() { + const cfg = Adapt.config.get('_spoor'); - if (!scorm.lmsConnected || (cfg && cfg._isEnabled === false)) return true; + if (!this.scorm.lmsConnected || (cfg && cfg._isEnabled === false)) return true; return false; - }, + } /** * Returns an object with the properties: * - id (cmi.core.student_id) - * - name (cmi.core.student_name - which is usually in the format "Lastname, Firstname" - but sometimes doesn't have the space after the comma) + * - name (cmi.core.student_name - which is usually in the format + * 'Lastname, Firstname' or 'Firstname Lastname' - but it sometimes doesn't + * have the space after the comma + * ) * - firstname * - lastname */ - getLearnerInfo: function() { - var name = scorm.getStudentName(); - var firstname = "", lastname = ""; - if (name && name !== 'undefined' && name.indexOf(",") > -1) { - //last name first, comma separated - var nameSplit = name.split(","); - lastname = $.trim(nameSplit[0]); - firstname = $.trim(nameSplit[1]); - name = firstname + " " + lastname; - } else { - console.log("SPOOR: LMS learner_name not in 'lastname, firstname' format"); + getLearnerInfo() { + const id = this.scorm.getStudentId(); + + let name = this.scorm.getStudentName(); + let firstname = ''; + let lastname = ''; + + let hasName = (name && name !== 'undefined'); + const isNameCommaSeparated = hasName && name.includes(','); + const isNameSpaceSeparated = hasName && name.includes(' '); + + // Name must have either a comma or a space + hasName = hasName && (isNameCommaSeparated || isNameSpaceSeparated); + + if (!hasName) { + console.log(`SPOOR: LMS learner_name not in 'lastname, firstname' or 'firstname lastname' format`); + return { id, name, firstname, lastname }; } - return { - name: name, - lastname: lastname, - firstname: firstname, - id: scorm.getStudentId() - }; + + const separator = isNameCommaSeparated ? ',' : ' '; + const nameParts = name.split(separator); + if (isNameCommaSeparated) { + // Assume lastname appears before the comma + nameParts.reverse(); + } + [ firstname, lastname ] = nameParts.map(part => part.trim()); + name = `${firstname} ${lastname}`; + return { id, name, firstname, lastname }; } - }); + } + + return OfflineStorage; }); diff --git a/js/adapt-stateful-session.js b/js/adapt-stateful-session.js index 564316bb..8fc395d8 100644 --- a/js/adapt-stateful-session.js +++ b/js/adapt-stateful-session.js @@ -1,227 +1,258 @@ define([ 'core/js/adapt', - './serializers/default', - './serializers/questions', - 'core/js/enums/completionStateEnum' -], function(Adapt, serializer, questions, COMPLETION_STATE) { - - // Implements Adapt session statefulness - - var AdaptStatefulSession = _.extend({ - - _config: null, - _shouldStoreResponses: true, - _shouldRecordInteractions: true, - - // Session Begin - initialize: function(callback) { - this._onWindowUnload = this.onWindowUnload.bind(this); - - this.getConfig(); - - this.getLearnerInfo(); - - // Restore state asynchronously to prevent IE8 freezes - this.restoreSessionState(function() { - // still need to defer call because AdaptModel.check*Status functions are asynchronous - _.defer(this.setupEventListeners.bind(this)); - callback(); - }.bind(this)); - }, - - getConfig: function() { - this._config = Adapt.config.has('_spoor') ? Adapt.config.get('_spoor') : false; - - this._shouldStoreResponses = (this._config && this._config._tracking && this._config._tracking._shouldStoreResponses); + './scorm/wrapper', + 'core/js/enums/completionStateEnum', + './serializers/ComponentSerializer', + './serializers/SCORMSuspendData' +], function(Adapt, ScormWrapper, COMPLETION_STATE, ComponentSerializer, SCORMSuspendData) { + + class StatefulSession extends Backbone.Controller { + + initialize() { + _.bindAll(this, 'beginSession', 'onVisibilityChange', 'endSession'); + this.scorm = ScormWrapper.getInstance(); + this._trackingIdType = 'block'; + this._componentSerializer = null; + this._shouldStoreResponses = true; + this._shouldRecordInteractions = true; + this.beginSession(); + } - // Default should be to record interactions, so only avoid doing that if _shouldRecordInteractions is set to false - if (this._config && this._config._tracking && this._config._tracking._shouldRecordInteractions === false) { + beginSession() { + this.listenTo(Adapt, 'app:dataReady', this.restoreSession); + this._trackingIdType = Adapt.build.get('trackingIdType') || 'block'; + this._componentSerializer = new ComponentSerializer(this._trackingIdType); + // suppress SCORM errors if 'nolmserrors' is found in the querystring + if (window.location.search.indexOf('nolmserrors') !== -1) { + this.scorm.suppressErrors = true; + } + const config = Adapt.spoor.config; + if (!config) return; + const tracking = config._tracking; + this._shouldStoreResponses = (tracking && tracking._shouldStoreResponses); + // Default should be to record interactions, so only avoid doing that if + // _shouldRecordInteractions is set to false + if (tracking && tracking._shouldRecordInteractions === false) { this._shouldRecordInteractions = false; } - }, + const settings = config._advancedSettings; + if (!settings) { + // force use of SCORM 1.2 by default - some LMSes (SABA/Kallidus for instance) + // present both APIs to the SCO and, if given the choice, the pipwerks + // code will automatically select the SCORM 2004 API - which can lead to + // unexpected behaviour. + this.scorm.setVersion('1.2'); + this.scorm.initialize(); + return; + } + if (settings._showDebugWindow) { + this.scorm.showDebugWindow(); + } + this.scorm.setVersion(settings._scormVersion || '1.2'); + if (settings._suppressErrors) { + this.scorm.suppressErrors = settings._suppressErrors; + } + if (settings._commitOnStatusChange) { + this.scorm.commitOnStatusChange = settings._commitOnStatusChange; + } + if (_.isFinite(settings._timedCommitFrequency)) { + this.scorm.timedCommitFrequency = settings._timedCommitFrequency; + } + if (_.isFinite(settings._maxCommitRetries)) { + this.scorm.maxCommitRetries = settings._maxCommitRetries; + } + if (_.isFinite(settings._commitRetryDelay)) { + this.scorm.commitRetryDelay = settings._commitRetryDelay; + } + if ('_exitStateIfIncomplete' in settings) { + this.scorm.exitStateIfIncomplete = settings._exitStateIfIncomplete; + } + if ('_exitStateIfComplete' in settings) { + this.scorm.exitStateIfComplete = settings._exitStateIfComplete; + } + this.scorm.initialize(); + } + + restoreSession() { + this.setupLearnerInfo(); + this.restoreSessionState(); + // defer call because AdaptModel.check*Status functions are asynchronous + _.defer(this.setupEventListeners.bind(this)); + } - /** - * Replace the hard-coded _learnerInfo data in _globals with the actual data from the LMS - * If the course has been published from the AT, the _learnerInfo object won't exist so we'll need to create it - */ - getLearnerInfo: function() { - var globals = Adapt.course.get('_globals'); + setupLearnerInfo() { + // Replace the hard-coded _learnerInfo data in _globals with the actual data + // from the LMS + // If the course has been published from the AT, the _learnerInfo object + // won't exist so we'll need to create it + const globals = Adapt.course.get('_globals'); if (!globals._learnerInfo) { globals._learnerInfo = {}; } - _.extend(globals._learnerInfo, Adapt.offlineStorage.get("learnerinfo")); - }, - - saveSessionState: function() { - var sessionPairs = this.getSessionState(); - Adapt.offlineStorage.set(sessionPairs); - }, - - restoreSessionState: function(callback) { - var sessionPairs = Adapt.offlineStorage.get(); - var hasNoPairs = _.keys(sessionPairs).length === 0; - - var courseState; - var doSynchronousPart = function() { - if (sessionPairs.q && this._shouldStoreResponses) questions.deserialize(sessionPairs.q); - if (courseState) Adapt.course.set('_isComplete', courseState[0]); - if (courseState) Adapt.course.set('_isAssessmentPassed', courseState[1]); - callback(); - }.bind(this); - - if (hasNoPairs) return callback(); + Object.assign(globals._learnerInfo, Adapt.offlineStorage.get('learnerinfo')); + } + restoreSessionState() { + const sessionPairs = Adapt.offlineStorage.get(); + const hasNoPairs = !Object.keys(sessionPairs).length; + if (hasNoPairs) return; if (sessionPairs.c) { - courseState = SCORMSuspendData.deserialize(sessionPairs.c); - } - - // Asynchronously restore block completion data because this has been known to be a choke-point resulting in IE8 freezes - // @oliverfoster: this can probably be removed in any subsequent rewrite - if (courseState) { - serializer.deserialize(courseState.slice(2).map(Number).map(String).join(''), doSynchronousPart); - } else { - doSynchronousPart(); - } - }, - - getSessionState: function() { - var blockCompletion = serializer.serialize(); - var courseComplete = Adapt.course.get('_isComplete') || false; - var assessmentPassed = Adapt.course.get('_isAssessmentPassed') || false; - Adapt.log.info(`course._isComplete: ${courseComplete}, course._isAssessmentPassed: ${assessmentPassed}, BlockCompletion: ${blockCompletion}`); - var courseState = SCORMSuspendData.serialize([ - courseComplete, - assessmentPassed, - ...blockCompletion.split('').map(Number).map(Boolean) - ]); - var sessionPairs = { - 'c': courseState, - 'q': (this._shouldStoreResponses === true ? questions.serialize() : '') - }; - return sessionPairs; - }, - - // Session In Progress - setupEventListeners: function() { - $(window).on('beforeunload unload', this._onWindowUnload); - - if (this._shouldStoreResponses) { - this.listenTo(Adapt.components, 'change:_isSubmitted', _.debounce(this.saveSessionState.bind(this), 1)); + const [ _isComplete, _isAssessmentPassed ] = SCORMSuspendData.deserialize(sessionPairs.c); + Adapt.course.set({ + _isComplete, + _isAssessmentPassed + }); } - - if (this._shouldRecordInteractions) { - this.listenTo(Adapt, 'questionView:recordInteraction', this.onQuestionRecordInteraction); + if (sessionPairs.q && this._shouldStoreResponses) { + this._componentSerializer.deserialize(sessionPairs.q); } + } - this.listenTo(Adapt.blocks, 'change:_isComplete', this.onBlockComplete); + setupEventListeners() { + const debouncedSaveSession = _.debounce(this.saveSessionState.bind(this), 1); + this.listenTo(Adapt.data, 'change:_isComplete', debouncedSaveSession); + if (this._shouldStoreResponses) { + this.listenTo(Adapt.data, 'change:_isSubmitted change:_userAnswer', debouncedSaveSession); + } this.listenTo(Adapt, { - 'assessment:complete': this.onAssessmentComplete, 'app:languageChanged': this.onLanguageChanged, + 'questionView:recordInteraction': this.onQuestionRecordInteraction, + 'assessment:complete': this.onAssessmentComplete, 'tracking:complete': this.onTrackingComplete }); - this.listenTo(Adapt.course, 'change:_isComplete', this.saveSessionState); - }, + const config = Adapt.spoor.config; + const advancedSettings = config._advancedSettings; + const shouldCommitOnVisibilityChange = (!advancedSettings || + advancedSettings._commitOnVisibilityChangeHidden !== false); + if (shouldCommitOnVisibilityChange) { + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + $(window).on('beforeunload unload', this.endSession); + } - removeEventListeners: function () { - $(window).off('beforeunload unload', this._onWindowUnload); - this.stopListening(); - }, + saveSessionState() { + const courseState = SCORMSuspendData.serialize([ + Boolean(Adapt.course.get('_isComplete')), + Boolean(Adapt.course.get('_isAssessmentPassed')) + ]); + const componentStates = (this._shouldStoreResponses === true) ? + this._componentSerializer.serialize() : + ''; + const sessionPairs = { + 'c': courseState, + 'q': componentStates + }; + this.printCompletionInformation(); + Adapt.offlineStorage.set(sessionPairs); + } - reattachEventListeners: function() { + printCompletionInformation() { + const courseComplete = Boolean(Adapt.course.get('_isComplete')); + const assessmentPassed = Boolean(Adapt.course.get('_isAssessmentPassed')); + const trackingIdModels = Adapt.data.filter(model => model.get('_type') === this._trackingIdType && model.has('_trackingId')); + const trackingIds = trackingIdModels.map(model => model.get('_trackingId')); + if (!trackingIds.length) { + Adapt.log.info(`course._isComplete: ${courseComplete}, course._isAssessmentPassed: ${assessmentPassed}, ${this._trackingIdType} completion: no tracking ids found`); + return; + } + const max = Math.max(...trackingIds); + const completion = trackingIdModels.reduce((completion, model) => { + const trackingId = model.get('_trackingId'); + completion[trackingId] = model.get('_isComplete') ? '1' : '0'; + return completion; + }, (new Array(max + 1)).join('-').split('')).join(''); + Adapt.log.info(`course._isComplete: ${courseComplete}, course._isAssessmentPassed: ${assessmentPassed}, ${this._trackingIdType} completion: ${completion}`); + } + + onLanguageChanged() { + // when the user switches language, we need to: + // - reattach the event listeners as the language change triggers a reload of + // the json, which will create brand new collections + // - get and save a fresh copy of the session state. as the json has been reloaded, + // the blocks completion data will be reset (the user is warned that this will + // happen by the language picker extension) + // - check to see if the config requires that the lesson_status be reset to + // 'incomplete' + const config = Adapt.spoor.config; this.removeEventListeners(); this.setupEventListeners(); - }, - - onBlockComplete: function(block) { this.saveSessionState(); - }, + if (config && config._reporting && config._reporting._resetStatusOnLanguageChange === true) { + Adapt.offlineStorage.set('status', 'incomplete'); + } + } - onTrackingComplete: function(completionData) { - this.saveSessionState(); + onVisibilityChange() { + if (document.visibilityState === 'hidden') this.scorm.commit(); + } - var completionStatus = completionData.status.asLowerCase; + onQuestionRecordInteraction(questionView) { + if (!this._shouldRecordInteractions) return; + const responseType = questionView.getResponseType(); + // If responseType doesn't contain any data, assume that the question + // component hasn't been set up for cmi.interaction tracking + if (_.isEmpty(responseType)) return; + const id = questionView.model.get('_id'); + const response = questionView.getResponse(); + const result = questionView.isCorrect(); + const latency = questionView.getLatency(); + Adapt.offlineStorage.set('interaction', id, response, result, latency, responseType); + } + onAssessmentComplete(stateModel) { + const config = Adapt.spoor.config; + Adapt.course.set('_isAssessmentPassed', stateModel.isPass); + this.saveSessionState(); + const shouldSubmitScore = (config && config._tracking && config._tracking._shouldSubmitScore); + if (!shouldSubmitScore) return; + const scoreArgs = stateModel.isPercentageBased ? + [ stateModel.scoreAsPercent, 0, 100 ] : + [ stateModel.score, 0, stateModel.maxScore ]; + Adapt.offlineStorage.set('score', ...scoreArgs); + } + + onTrackingComplete(completionData) { + const config = Adapt.spoor.config; + this.saveSessionState(); + let completionStatus = completionData.status.asLowerCase; // The config allows the user to override the completion state. switch (completionData.status) { case COMPLETION_STATE.COMPLETED: case COMPLETION_STATE.PASSED: { - if (!this._config._reporting._onTrackingCriteriaMet) { - Adapt.log.warn("No value defined for '_onTrackingCriteriaMet', so defaulting to '" + completionStatus + "'"); + if (!config || !config._reporting || !config._reporting._onTrackingCriteriaMet) { + Adapt.log.warn(`No value defined for '_onTrackingCriteriaMet', so defaulting to '${completionStatus}'`); } else { - completionStatus = this._config._reporting._onTrackingCriteriaMet; + completionStatus = config._reporting._onTrackingCriteriaMet; } break; } - case COMPLETION_STATE.FAILED: { - if (!this._config._reporting._onAssessmentFailure) { - Adapt.log.warn("No value defined for '_onAssessmentFailure', so defaulting to '" + completionStatus + "'"); + if (!config || !config._reporting || !config._reporting._onAssessmentFailure) { + Adapt.log.warn(`No value defined for '_onAssessmentFailure', so defaulting to '${completionStatus}'`); } else { - completionStatus = this._config._reporting._onAssessmentFailure; + completionStatus = config._reporting._onAssessmentFailure; } } } + Adapt.offlineStorage.set('status', completionStatus); + } - Adapt.offlineStorage.set("status", completionStatus); - }, - - onAssessmentComplete: function(stateModel) { - Adapt.course.set('_isAssessmentPassed', stateModel.isPass); - - this.saveSessionState(); - - this.submitScore(stateModel); - }, - - onQuestionRecordInteraction:function(questionView) { - var responseType = questionView.getResponseType(); - - // If responseType doesn't contain any data, assume that the question - // component hasn't been set up for cmi.interaction tracking - if(_.isEmpty(responseType)) return; - - var id = questionView.model.get('_id'); - var response = questionView.getResponse(); - var result = questionView.isCorrect(); - var latency = questionView.getLatency(); - - Adapt.offlineStorage.set("interaction", id, response, result, latency, responseType); - }, - - /** - * when the user switches language, we need to: - * - reattach the event listeners as the language change triggers a reload of the json, which will create brand new collections - * - get and save a fresh copy of the session state. as the json has been reloaded, the blocks completion data will be reset (the user is warned that this will happen by the language picker extension) - * - check to see if the config requires that the lesson_status be reset to 'incomplete' - */ - onLanguageChanged: function () { - this.reattachEventListeners(); - - this.saveSessionState(); - - if (this._config._reporting && this._config._reporting._resetStatusOnLanguageChange === true) { - Adapt.offlineStorage.set("status", "incomplete"); - } - }, - - submitScore: function(stateModel) { - if (this._config && !this._config._tracking._shouldSubmitScore) return; - - if (stateModel.isPercentageBased) { - Adapt.offlineStorage.set("score", stateModel.scoreAsPercent, 0, 100); - } else { - Adapt.offlineStorage.set("score", stateModel.score, 0, stateModel.maxScore); + endSession() { + if (!this.scorm.finishCalled) { + this.scorm.finish(); } - }, - - // Session End - onWindowUnload: function() { this.removeEventListeners(); } - }, Backbone.Events); + removeEventListeners() { + $(window).off('beforeunload unload', this.endSession); + document.removeEventListener('visibilitychange', this.onVisibilityChange); + this.stopListening(); + } + + } - return AdaptStatefulSession; + return StatefulSession; }); diff --git a/js/scorm.js b/js/scorm.js deleted file mode 100644 index dc6816e1..00000000 --- a/js/scorm.js +++ /dev/null @@ -1,11 +0,0 @@ -define([ - 'libraries/SCORM_API_wrapper', - './scorm/wrapper', - './scorm/logger' -], function(API, wrapper, logger) { - - //Load and prepare SCORM API - - return wrapper.getInstance(); - -}); diff --git a/js/scorm/logger.js b/js/scorm/logger.js index c4c032ff..45ab0a4a 100644 --- a/js/scorm/logger.js +++ b/js/scorm/logger.js @@ -1,68 +1,74 @@ -Logger = function() { - this.logArr = []; - this.registeredViews = []; -}; - -// static -Logger.instance = null; -Logger.LOG_TYPE_INFO = 0; -Logger.LOG_TYPE_WARN = 1; -Logger.LOG_TYPE_ERROR = 2; -Logger.LOG_TYPE_DEBUG = 3; - -Logger.getInstance = function() { - if (Logger.instance == null) - Logger.instance = new Logger(); - return Logger.instance; -}; - -Logger.prototype.getEntries = function() { - return this.logArr; -}; - -Logger.prototype.getLastEntry = function() { - return this.logArr[this.logArr.length - 1]; -}; - -Logger.prototype.info = function(str) { - this.logArr[this.logArr.length] = {str:str, type:Logger.LOG_TYPE_INFO, time:Date.now()}; - this.updateViews(); -}; - -Logger.prototype.warn = function(str) { - this.logArr[this.logArr.length] = {str:str, type:Logger.LOG_TYPE_WARN, time:Date.now()}; - this.updateViews(); -}; - -Logger.prototype.error = function(str) { - this.logArr[this.logArr.length] = {str:str, type:Logger.LOG_TYPE_ERROR, time:Date.now()}; - this.updateViews(); -}; - -Logger.prototype.debug = function(str) { - this.logArr[this.logArr.length] = {str:str, type:Logger.LOG_TYPE_DEBUG, time:Date.now()}; - this.updateViews(); -}; - -//register a view -Logger.prototype.registerView = function(_view) { - this.registeredViews[this.registeredViews.length] = _view; -}; - -//unregister a view -Logger.prototype.unregisterView = function(_view) { - for (var i = 0; i < this.registeredViews.length; i++) { - if (this.registeredViews[i] == _view) { - this.registeredViews.splice(i, 1); - i--; +define(function() { + + class Logger { + + constructor() { + this.logArr = []; + this.registeredViews = []; + } + + static getInstance() { + if (Logger.instance === null) { + Logger.instance = new Logger(); + } + return Logger.instance; + } + + getEntries() { + return this.logArr; + } + + getLastEntry() { + return this.logArr[this.logArr.length - 1]; + } + + info(str) { + this.logArr[this.logArr.length] = { str: str, type: Logger.LOG_TYPE_INFO, time: Date.now() }; + this.updateViews(); + } + + warn(str) { + this.logArr[this.logArr.length] = { str: str, type: Logger.LOG_TYPE_WARN, time: Date.now() }; + this.updateViews(); } - } -}; -// update all views -Logger.prototype.updateViews = function() { - for (var i = 0; i < this.registeredViews.length; i++) { - if (this.registeredViews[i]) + error(str) { + this.logArr[this.logArr.length] = { str: str, type: Logger.LOG_TYPE_ERROR, time: Date.now() }; + this.updateViews(); + } + + debug(str) { + this.logArr[this.logArr.length] = { str: str, type: Logger.LOG_TYPE_DEBUG, time: Date.now() }; + this.updateViews(); + } + + registerView(_view) { + this.registeredViews[this.registeredViews.length] = _view; + } + + unregisterView(_view) { + for (let i = 0, l = this.registeredViews.length; i < l; i++) { + if (this.registeredViews[i] !== _view) continue; + this.registeredViews.splice(i, 1); + i--; + } + } + + updateViews() { + for (let i = 0, l = this.registeredViews.length; i < l; i++) { + if (!this.registeredViews[i]) continue; this.registeredViews[i].update(this); + } + } + } -}; + + Logger.instance = null; + Logger.LOG_TYPE_INFO = 0; + Logger.LOG_TYPE_WARN = 1; + Logger.LOG_TYPE_ERROR = 2; + Logger.LOG_TYPE_DEBUG = 3; + + return Logger; + +}); diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index fc0efc67..85ab6e21 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -1,302 +1,298 @@ -define ([ - 'libraries/SCORM_API_wrapper' -], function(pipwerks) { +define([ + 'libraries/SCORM_API_wrapper', + './logger' +], function(pipwerks, Logger) { - /* - IMPORTANT: This wrapper uses the Pipwerks SCORM wrapper and should therefore support both SCORM 1.2 and 2004. Ensure any changes support both versions. + /** + * IMPORTANT: This wrapper uses the Pipwerks SCORM wrapper and should therefore support both SCORM 1.2 and 2004. Ensure any changes support both versions. */ + class ScormWrapper { - var ScormWrapper = function() { + constructor() { /* configuration */ - this.setCompletedWhenFailed = true;// this only applies to SCORM 2004 - /** + this.setCompletedWhenFailed = true;// this only applies to SCORM 2004 + /** * whether to commit each time there's a change to lesson_status or not */ - this.commitOnStatusChange = true; - /** + this.commitOnStatusChange = true; + /** * how frequently (in minutes) to commit automatically. set to 0 to disable. */ - this.timedCommitFrequency = 10; - /** + this.timedCommitFrequency = 10; + /** * how many times to retry if a commit fails */ - this.maxCommitRetries = 5; - /** + this.maxCommitRetries = 5; + /** * time (in milliseconds) to wait between retries */ - this.commitRetryDelay = 1000; + this.commitRetryDelay = 1000; - /** + /** * prevents commit from being called if there's already a 'commit retry' pending. */ - this.commitRetryPending = false; - /** + this.commitRetryPending = false; + /** * how many times we've done a 'commit retry' */ - this.commitRetries = 0; - /** + this.commitRetries = 0; + /** * not currently used - but you could include in an error message to show when data was last saved */ - this.lastCommitSuccessTime = null; - /** + this.lastCommitSuccessTime = null; + /** * The exit state to use when course isn't completed yet */ - this.exitStateIfIncomplete = "auto"; - /** + this.exitStateIfIncomplete = 'auto'; + /** * The exit state to use when the course has been completed/passed */ - this.exitStateIfComplete = "auto"; + this.exitStateIfComplete = 'auto'; + this.timedCommitIntervalID = null; + this.retryCommitTimeoutID = null; + this.logOutputWin = null; + this.startTime = null; + this.endTime = null; - this.timedCommitIntervalID = null; - this.retryCommitTimeoutID = null; - this.logOutputWin = null; - this.startTime = null; - this.endTime = null; - - this.lmsConnected = false; - this.finishCalled = false; + this.lmsConnected = false; + this.finishCalled = false; - this.logger = Logger.getInstance(); - this.scorm = pipwerks.SCORM; - /** + this.logger = Logger.getInstance(); + this.scorm = pipwerks.SCORM; + /** * Prevent the Pipwerks SCORM API wrapper's handling of the exit status */ - this.scorm.handleExitMode = false; + this.scorm.handleExitMode = false; - this.suppressErrors = false; + this.suppressErrors = false; - if (window.__debug) + if (window.__debug) { this.showDebugWindow(); + } - if ((window.API && window.API.__offlineAPIWrapper) || (window.API_1484_11 && window.API_1484_11.__offlineAPIWrapper)) - this.logger.error("Offline SCORM API is being used. No data will be reported to the LMS!"); - }; - - // static - ScormWrapper.instance = null; + if ((window.API && window.API.__offlineAPIWrapper) || (window.API_1484_11 && window.API_1484_11.__offlineAPIWrapper)) { + this.logger.error('Offline SCORM API is being used. No data will be reported to the LMS!'); + } + } - /******************************* public methods *******************************/ + // ******************************* public methods ******************************* - // static - ScormWrapper.getInstance = function() { - if (ScormWrapper.instance === null) + static getInstance() { + if (ScormWrapper.instance === null) { ScormWrapper.instance = new ScormWrapper(); + } - return ScormWrapper.instance; - }; + return ScormWrapper.instance; + } + + getVersion() { + return this.scorm.version; + } - ScormWrapper.prototype.getVersion = function() { - return this.scorm.version; - }; + setVersion(value) { + this.logger.debug(`ScormWrapper::setVersion: ${value}`); + this.scorm.version = value; + } - ScormWrapper.prototype.setVersion = function(value) { - this.logger.debug("ScormWrapper::setVersion: " + value); - this.scorm.version = value; - }; + initialize() { + this.logger.debug('ScormWrapper::initialize'); + this.lmsConnected = this.scorm.init(); - ScormWrapper.prototype.initialize = function() { - this.logger.debug("ScormWrapper::initialize"); - this.lmsConnected = this.scorm.init(); + if (this.lmsConnected) { + this.startTime = new Date(); - if (this.lmsConnected) { - this.startTime = new Date(); + this.initTimedCommit(); + } else { + this.handleError('Course could not connect to the LMS'); + } - this.initTimedCommit(); + return this.lmsConnected; } - else { - this.handleError("Course could not connect to the LMS"); - } - - return this.lmsConnected; - }; - /** + /** * allows you to check if this is the user's first ever 'session' of a SCO, even after the lesson_status has been set to 'incomplete' */ - ScormWrapper.prototype.isFirstSession = function() { - return (this.getValue(this.isSCORM2004() ? "cmi.entry" :"cmi.core.entry") === "ab-initio"); - }; - - ScormWrapper.prototype.setIncomplete = function() { - this.setValue(this.isSCORM2004() ? "cmi.completion_status" : "cmi.core.lesson_status", "incomplete"); - - if (this.commitOnStatusChange) this.commit(); - }; - - ScormWrapper.prototype.setCompleted = function() { - this.setValue(this.isSCORM2004() ? "cmi.completion_status" : "cmi.core.lesson_status", "completed"); + isFirstSession() { + return (this.getValue(this.isSCORM2004() ? 'cmi.entry' : 'cmi.core.entry') === 'ab-initio'); + } - if (this.commitOnStatusChange) this.commit(); - }; + setIncomplete() { + this.setValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status', 'incomplete'); - ScormWrapper.prototype.setPassed = function() { - if (this.isSCORM2004()) { - this.setValue("cmi.completion_status", "completed"); - this.setValue("cmi.success_status", "passed"); - } - else { - this.setValue("cmi.core.lesson_status", "passed"); + if (this.commitOnStatusChange) this.commit(); } - if (this.commitOnStatusChange) this.commit(); - }; + setCompleted() { + this.setValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status', 'completed'); - ScormWrapper.prototype.setFailed = function() { - if (this.isSCORM2004()) { - this.setValue("cmi.success_status", "failed"); + if (this.commitOnStatusChange) this.commit(); + } - if (this.setCompletedWhenFailed) { - this.setValue("cmi.completion_status", "completed"); + setPassed() { + if (this.isSCORM2004()) { + this.setValue('cmi.completion_status', 'completed'); + this.setValue('cmi.success_status', 'passed'); + } else { + this.setValue('cmi.core.lesson_status', 'passed'); } - } - else { - this.setValue("cmi.core.lesson_status", "failed"); + + if (this.commitOnStatusChange) this.commit(); } - if (this.commitOnStatusChange) this.commit(); - }; + setFailed() { + if (this.isSCORM2004()) { + this.setValue('cmi.success_status', 'failed'); - ScormWrapper.prototype.getStatus = function() { - var status = this.getValue(this.isSCORM2004() ? "cmi.completion_status" : "cmi.core.lesson_status"); + if (this.setCompletedWhenFailed) { + this.setValue('cmi.completion_status', 'completed'); + } + } else { + this.setValue('cmi.core.lesson_status', 'failed'); + } - switch(status.toLowerCase()) {// workaround for some LMSes (e.g. Arena) not adhering to the all-lowercase rule - case "passed": - case "completed": - case "incomplete": - case "failed": - case "browsed": - case "not attempted": - case "not_attempted":// mentioned in SCORM 2004 docs but not sure it ever gets used - case "unknown": //the SCORM 2004 version of not attempted - return status; - default: - this.handleError("ScormWrapper::getStatus: invalid lesson status '" + status + "' received from LMS"); - return null; + if (this.commitOnStatusChange) this.commit(); } - }; - ScormWrapper.prototype.setStatus = function(status) { - switch (status.toLowerCase()){ - case "incomplete": - this.setIncomplete(); - break; - case "completed": - this.setCompleted(); - break; - case "passed": - this.setPassed(); - break; - case "failed": - this.setFailed(); - break; - default: - this.handleError("ScormWrapper::setStatus: the status '" + status + "' is not supported."); + getStatus() { + const status = this.getValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status'); + + switch (status.toLowerCase()) { // workaround for some LMSes (e.g. Arena) not adhering to the all-lowercase rule + case 'passed': + case 'completed': + case 'incomplete': + case 'failed': + case 'browsed': + case 'not attempted': + case 'not_attempted': // mentioned in SCORM 2004 docs but not sure it ever gets used + case 'unknown': // the SCORM 2004 version of not attempted + return status; + default: + this.handleError(`ScormWrapper::getStatus: invalid lesson status '${status}' received from LMS`); + return null; + } } - }; - ScormWrapper.prototype.getScore = function() { - return this.getValue(this.isSCORM2004() ? "cmi.score.raw" : "cmi.core.score.raw"); - }; + setStatus(status) { + switch (status.toLowerCase()) { + case 'incomplete': + this.setIncomplete(); + break; + case 'completed': + this.setCompleted(); + break; + case 'passed': + this.setPassed(); + break; + case 'failed': + this.setFailed(); + break; + default: + this.handleError(`ScormWrapper::setStatus: the status '${status}' is not supported.`); + } + } - ScormWrapper.prototype.setScore = function(_score, _minScore, _maxScore) { - if (this.isSCORM2004()) { - this.setValue("cmi.score.raw", _score); - this.setValue("cmi.score.min", _minScore); - this.setValue("cmi.score.max", _maxScore); + getScore() { + return this.getValue(this.isSCORM2004() ? 'cmi.score.raw' : 'cmi.core.score.raw'); + } - var range = _maxScore - _minScore; - var scaledScore = ((_score - _minScore) / range).toFixed(7); - this.setValue("cmi.score.scaled", scaledScore); + setScore(_score, _minScore, _maxScore) { + if (this.isSCORM2004()) { + this.setValue('cmi.score.raw', _score); + this.setValue('cmi.score.min', _minScore); + this.setValue('cmi.score.max', _maxScore); + + const range = _maxScore - _minScore; + const scaledScore = ((_score - _minScore) / range).toFixed(7); + this.setValue('cmi.score.scaled', scaledScore); + return; + } + // SCORM 1.2 + this.setValue('cmi.core.score.raw', _score); + if (this.isSupported('cmi.core.score.min')) this.setValue('cmi.core.score.min', _minScore); + if (this.isSupported('cmi.core.score.max')) this.setValue('cmi.core.score.max', _maxScore); } - else { - this.setValue("cmi.core.score.raw", _score); - if (this.isSupported("cmi.core.score.min")) this.setValue("cmi.core.score.min", _minScore); + getLessonLocation() { + return this.getValue(this.isSCORM2004() ? 'cmi.location' : 'cmi.core.lesson_location'); + } - if (this.isSupported("cmi.core.score.max")) this.setValue("cmi.core.score.max", _maxScore); + setLessonLocation(_location) { + this.setValue(this.isSCORM2004() ? 'cmi.location' : 'cmi.core.lesson_location', _location); } - }; - ScormWrapper.prototype.getLessonLocation = function() { - return this.getValue(this.isSCORM2004() ? "cmi.location" : "cmi.core.lesson_location"); - }; + getSuspendData() { + return this.getValue('cmi.suspend_data'); + } - ScormWrapper.prototype.setLessonLocation = function(_location) { - this.setValue(this.isSCORM2004() ? "cmi.location" : "cmi.core.lesson_location", _location); - }; + setSuspendData(_data) { + this.setValue('cmi.suspend_data', _data); + } - ScormWrapper.prototype.getSuspendData = function() { - return this.getValue("cmi.suspend_data"); - }; + getStudentName() { + return this.getValue(this.isSCORM2004() ? 'cmi.learner_name' : 'cmi.core.student_name'); + } - ScormWrapper.prototype.setSuspendData = function(_data) { - this.setValue("cmi.suspend_data", _data); - }; + getStudentId() { + return this.getValue(this.isSCORM2004() ? 'cmi.learner_id' : 'cmi.core.student_id'); + } - ScormWrapper.prototype.getStudentName = function() { - return this.getValue(this.isSCORM2004() ? "cmi.learner_name" : "cmi.core.student_name"); - }; + setLanguage(_lang) { + if (this.isSCORM2004()) { + this.setValue('cmi.learner_preference.language', _lang); + return; + } + if (this.isSupported('cmi.student_preference.language')) { + this.setValue('cmi.student_preference.language', _lang); + } + } - ScormWrapper.prototype.getStudentId = function(){ - return this.getValue(this.isSCORM2004() ? "cmi.learner_id":"cmi.core.student_id"); - }; + commit() { + this.logger.debug('ScormWrapper::commit'); - ScormWrapper.prototype.setLanguage = function(_lang){ - if (this.isSCORM2004()) { - this.setValue("cmi.learner_preference.language", _lang); - } else { - if (this.isSupported("cmi.student_preference.language")) { - this.setValue("cmi.student_preference.language", _lang); + if (!this.lmsConnected) { + this.handleError('Course is not connected to the LMS'); + return; } - } - }; - - ScormWrapper.prototype.commit = function() { - this.logger.debug("ScormWrapper::commit"); - - if (this.lmsConnected) { + if (this.commitRetryPending) { - this.logger.debug("ScormWrapper::commit: skipping this commit call as one is already pending."); - } - else { + this.logger.debug('ScormWrapper::commit: skipping this commit call as one is already pending.'); + } else { if (this.scorm.save()) { this.commitRetries = 0; this.lastCommitSuccessTime = new Date(); - } - else { + } else { if (this.commitRetries < this.maxCommitRetries && !this.finishCalled) { this.commitRetries++; this.initRetryCommit(); - } - else { - var _errorCode = this.scorm.debug.getCode(); + } else { + const _errorCode = this.scorm.debug.getCode(); - var _errorMsg = "Course could not commit data to the LMS"; - _errorMsg += "\nError " + _errorCode + ": " + this.scorm.debug.getInfo(_errorCode); - _errorMsg += "\nLMS Error Info: " + this.scorm.debug.getDiagnosticInfo(_errorCode); + let _errorMsg = 'Course could not commit data to the LMS'; + _errorMsg += `\nError ${_errorCode}: ${this.scorm.debug.getInfo(_errorCode)}`; + _errorMsg += `\nLMS Error Info: ${this.scorm.debug.getDiagnosticInfo(_errorCode)}`; this.handleError(_errorMsg); } } } } - else { - this.handleError("Course is not connected to the LMS"); - } - }; - ScormWrapper.prototype.finish = function() { - this.logger.debug("ScormWrapper::finish"); + finish() { + this.logger.debug('ScormWrapper::finish'); + + if (!this.lmsConnected || this.finishCalled) { + this.handleError('Course is not connected to the LMS'); + return; + } - if (this.lmsConnected && !this.finishCalled) { this.finishCalled = true; if (this.timedCommitIntervalID !== null) { window.clearInterval(this.timedCommitIntervalID); } - if(this.commitRetryPending) { + if (this.commitRetryPending) { window.clearTimeout(this.retryCommitTimeoutID); this.commitRetryPending = false; } @@ -308,433 +304,429 @@ define ([ this.endTime = new Date(); if (this.isSCORM2004()) { - this.scorm.set("cmi.session_time", this.convertToSCORM2004Time(this.endTime.getTime() - this.startTime.getTime())); - this.scorm.set("cmi.exit", this.getExitState()); + this.scorm.set('cmi.session_time', this.convertToSCORM2004Time(this.endTime.getTime() - this.startTime.getTime())); + this.scorm.set('cmi.exit', this.getExitState()); } else { - this.scorm.set("cmi.core.session_time", this.convertToSCORM12Time(this.endTime.getTime() - this.startTime.getTime())); - this.scorm.set("cmi.core.exit", this.getExitState()); + this.scorm.set('cmi.core.session_time', this.convertToSCORM12Time(this.endTime.getTime() - this.startTime.getTime())); + this.scorm.set('cmi.core.exit', this.getExitState()); } // api no longer available from this point this.lmsConnected = false; if (!this.scorm.quit()) { - this.handleError("Course could not finish"); + this.handleError('Course could not finish'); } } - else { - this.handleError("Course is not connected to the LMS"); - } - }; - ScormWrapper.prototype.recordInteraction = function(id, response, correct, latency, type) { - if(this.isSupported("cmi.interactions._count")) { - switch(type) { - case "choice": + recordInteraction(id, response, correct, latency, type) { + if (!this.isSupported('cmi.interactions._count')) { + this.logger.info('ScormWrapper::recordInteraction: cmi.interactions are not supported by this LMS...'); + return; + } + + switch (type) { + case 'choice': this.recordInteractionMultipleChoice.apply(this, arguments); break; - case "matching": + case 'matching': this.recordInteractionMatching.apply(this, arguments); break; - case "numeric": + case 'numeric': this.isSCORM2004() ? this.recordInteractionScorm2004.apply(this, arguments) : this.recordInteractionScorm12.apply(this, arguments); break; - case "fill-in": + case 'fill-in': this.recordInteractionFillIn.apply(this, arguments); break; default: - console.error("ScormWrapper.recordInteraction: unknown interaction type of '" + type + "' encountered..."); + console.error(`ScormWrapper.recordInteraction: unknown interaction type of '${type}' encountered...`); } } - else { - this.logger.info("ScormWrapper::recordInteraction: cmi.interactions are not supported by this LMS..."); - } - }; - /****************************** private methods ******************************/ - ScormWrapper.prototype.getValue = function(_property) { - this.logger.debug("ScormWrapper::getValue: _property=" + _property); + // ****************************** private methods ****************************** - if (this.finishCalled) { - this.logger.debug("ScormWrapper::getValue: ignoring request as 'finish' has been called"); - return; - } + getValue(_property) { + this.logger.debug(`ScormWrapper::getValue: _property=${_property}`); - if (this.lmsConnected) { - var _value = this.scorm.get(_property); - var _errorCode = this.scorm.debug.getCode(); - var _errorMsg = ""; + if (this.finishCalled) { + this.logger.debug(`ScormWrapper::getValue: ignoring request as 'finish' has been called`); + return; + } + + if (!this.lmsConnected) { + this.handleError('Course is not connected to the LMS'); + return; + } + + const _value = this.scorm.get(_property); + const _errorCode = this.scorm.debug.getCode(); + let _errorMsg = ''; if (_errorCode !== 0) { if (_errorCode === 403) { - this.logger.warn("ScormWrapper::getValue: data model element not initialized"); - } - else { - _errorMsg += "Course could not get " + _property; - _errorMsg += "\nError Info: " + this.scorm.debug.getInfo(_errorCode); - _errorMsg += "\nLMS Error Info: " + this.scorm.debug.getDiagnosticInfo(_errorCode); + this.logger.warn('ScormWrapper::getValue: data model element not initialized'); + } else { + _errorMsg += `Course could not get ${_property}`; + _errorMsg += `\nError Info: ${this.scorm.debug.getInfo(_errorCode)}`; + _errorMsg += `\nLMS Error Info: ${this.scorm.debug.getDiagnosticInfo(_errorCode)}`; this.handleError(_errorMsg); } } - this.logger.debug("ScormWrapper::getValue: returning " + _value); - return _value + ""; - } - else { - this.handleError("Course is not connected to the LMS"); + this.logger.debug(`ScormWrapper::getValue: returning ${_value}`); + return _value + ''; } - }; - ScormWrapper.prototype.setValue = function(_property, _value) { - this.logger.debug("ScormWrapper::setValue: _property=" + _property + " _value=" + _value); + setValue(_property, _value) { + this.logger.debug(`ScormWrapper::setValue: _property=${_property} _value=${_value}`); - if (this.finishCalled) { - this.logger.debug("ScormWrapper::setValue: ignoring request as 'finish' has been called"); - return; - } + if (this.finishCalled) { + this.logger.debug(`ScormWrapper::setValue: ignoring request as 'finish' has been called`); + return; + } - if (this.lmsConnected) { - var _success = this.scorm.set(_property, _value); - var _errorCode = this.scorm.debug.getCode(); - var _errorMsg = ""; + if (!this.lmsConnected) { + this.handleError('Course is not connected to the LMS'); + return; + } + + const _success = this.scorm.set(_property, _value); + const _errorCode = this.scorm.debug.getCode(); + let _errorMsg = ''; if (!_success) { - /* - * Some LMSes have an annoying tendency to return false from a set call even when it actually worked fine. - * So, we should throw an error _only_ if there was a valid error code... - */ - if(_errorCode !== 0) { - _errorMsg += "Course could not set " + _property + " to " + _value; - _errorMsg += "\nError Info: " + this.scorm.debug.getInfo(_errorCode); - _errorMsg += "\nLMS Error Info: " + this.scorm.debug.getDiagnosticInfo(_errorCode); + /* + * Some LMSes have an annoying tendency to return false from a set call even when it actually worked fine. + * So, we should throw an error _only_ if there was a valid error code... + */ + if (_errorCode !== 0) { + _errorMsg += `Course could not set ${_property} to ${_value}`; + _errorMsg += `\nError Info: ${this.scorm.debug.getInfo(_errorCode)}`; + _errorMsg += `\nLMS Error Info: ${this.scorm.debug.getDiagnosticInfo(_errorCode)}`; this.handleError(_errorMsg); - } - else { - this.logger.warn("ScormWrapper::setValue: LMS reported that the 'set' call failed but then said there was no error!"); + } else { + this.logger.warn(`ScormWrapper::setValue: LMS reported that the 'set' call failed but then said there was no error!`); } } return _success; } - else { - this.handleError("Course is not connected to the LMS"); - } - }; - /** + /** * used for checking any data field that is not 'LMS Mandatory' to see whether the LMS we're running on supports it or not. * Note that the way this check is being performed means it wouldn't work for any element that is * 'write only', but so far we've not had a requirement to check for any optional elements that are. */ - ScormWrapper.prototype.isSupported = function(_property) { - this.logger.debug("ScormWrapper::isSupported: _property=" + _property); + isSupported(_property) { + this.logger.debug(`ScormWrapper::isSupported: _property=${_property}`); - if (this.finishCalled) { - this.logger.debug("ScormWrapper::isSupported: ignoring request as 'finish' has been called"); - return; - } + if (this.finishCalled) { + this.logger.debug(`ScormWrapper::isSupported: ignoring request as 'finish' has been called`); + return; + } - if (this.lmsConnected) { - var _value = this.scorm.get(_property); - var _errorCode = this.scorm.debug.getCode(); + if (!this.lmsConnected) { + this.handleError('Course is not connected to the LMS'); + return false; + } - return (_errorCode === 401 ? false : true); - } - else { - this.handleError("Course is not connected to the LMS"); - return false; + this.scorm.get(_property); + + return (this.scorm.debug.getCode() === 401); } - }; - ScormWrapper.prototype.initTimedCommit = function() { - this.logger.debug("ScormWrapper::initTimedCommit"); + initTimedCommit() { + this.logger.debug('ScormWrapper::initTimedCommit'); - if (this.timedCommitFrequency > 0) { - var delay = this.timedCommitFrequency * (60 * 1000); - this.timedCommitIntervalID = window.setInterval(this.commit.bind(this), delay); + if (this.timedCommitFrequency > 0) { + const delay = this.timedCommitFrequency * (60 * 1000); + this.timedCommitIntervalID = window.setInterval(this.commit.bind(this), delay); + } } - }; - ScormWrapper.prototype.initRetryCommit = function() { - this.logger.debug("ScormWrapper::initRetryCommit " + this.commitRetries + " out of " + this.maxCommitRetries); + initRetryCommit() { + this.logger.debug(`ScormWrapper::initRetryCommit ${this.commitRetries} out of ${this.maxCommitRetries}`); - this.commitRetryPending = true;// stop anything else from calling commit until this is done + this.commitRetryPending = true;// stop anything else from calling commit until this is done - this.retryCommitTimeoutID = window.setTimeout(this.doRetryCommit.bind(this), this.commitRetryDelay); - }; + this.retryCommitTimeoutID = window.setTimeout(this.doRetryCommit.bind(this), this.commitRetryDelay); + } - ScormWrapper.prototype.doRetryCommit = function() { - this.logger.debug("ScormWrapper::doRetryCommit"); + doRetryCommit() { + this.logger.debug('ScormWrapper::doRetryCommit'); - this.commitRetryPending = false; + this.commitRetryPending = false; - this.commit(); - }; + this.commit(); + } - ScormWrapper.prototype.handleError = function(_msg) { - this.logger.error(_msg); + handleError(_msg) { + this.logger.error(_msg); - if (!this.suppressErrors && (!this.logOutputWin || this.logOutputWin.closed) && confirm("An error has occured:\n\n" + _msg + "\n\nPress 'OK' to view debug information to send to technical support.")) + if (!this.suppressErrors && (!this.logOutputWin || this.logOutputWin.closed) && confirm(`An error has occured:\n\n${_msg}\n\nPress 'OK' to view debug information to send to technical support.`)) { this.showDebugWindow(); - }; + } + } - ScormWrapper.prototype.getInteractionCount = function(){ - var count = this.getValue("cmi.interactions._count"); - return count === "" ? 0 : count; - }; + getInteractionCount() { + const count = this.getValue('cmi.interactions._count'); + return count === '' ? 0 : count; + } - ScormWrapper.prototype.recordInteractionScorm12 = function(id, response, correct, latency, type) { + recordInteractionScorm12(id, response, correct, latency, type) { - id = this.trim(id); + id = this.trim(id); - var cmiPrefix = "cmi.interactions." + this.getInteractionCount(); + const cmiPrefix = `cmi.interactions.${this.getInteractionCount()}`; - this.setValue(cmiPrefix + ".id", id); - this.setValue(cmiPrefix + ".type", type); - this.setValue(cmiPrefix + ".student_response", response); - this.setValue(cmiPrefix + ".result", correct ? "correct" : "wrong"); - if (latency !== null && latency !== undefined) this.setValue(cmiPrefix + ".latency", this.convertToSCORM12Time(latency)); - this.setValue(cmiPrefix + ".time", this.getCMITime()); - }; + this.setValue(`${cmiPrefix}.id`, id); + this.setValue(`${cmiPrefix}.type`, type); + this.setValue(`${cmiPrefix}.student_response`, response); + this.setValue(`${cmiPrefix}.result`, correct ? 'correct' : 'wrong'); + if (latency !== null && latency !== undefined) this.setValue(`${cmiPrefix}.latency`, this.convertToSCORM12Time(latency)); + this.setValue(`${cmiPrefix}.time`, this.getCMITime()); + } + recordInteractionScorm2004(id, response, correct, latency, type) { - ScormWrapper.prototype.recordInteractionScorm2004 = function(id, response, correct, latency, type) { + id = this.trim(id); - id = this.trim(id); + const cmiPrefix = `cmi.interactions.${this.getInteractionCount()}`; - var cmiPrefix = "cmi.interactions." + this.getInteractionCount(); + this.setValue(`${cmiPrefix}.id`, id); + this.setValue(`${cmiPrefix}.type`, type); + this.setValue(`${cmiPrefix}.learner_response`, response); + this.setValue(`${cmiPrefix}.result`, correct ? 'correct' : 'incorrect'); + if (latency !== null && latency !== undefined) this.setValue(`${cmiPrefix}.latency`, this.convertToSCORM2004Time(latency)); + this.setValue(`${cmiPrefix}.timestamp`, this.getISO8601Timestamp()); + } - this.setValue(cmiPrefix + ".id", id); - this.setValue(cmiPrefix + ".type", type); - this.setValue(cmiPrefix + ".learner_response", response); - this.setValue(cmiPrefix + ".result", correct ? "correct" : "incorrect"); - if (latency !== null && latency !== undefined) this.setValue(cmiPrefix + ".latency", this.convertToSCORM2004Time(latency)); - this.setValue(cmiPrefix + ".timestamp", this.getISO8601Timestamp()); - }; + recordInteractionMultipleChoice(id, response, correct, latency, type) { + if (this.isSCORM2004()) { + response = response.replace(/,|#/g, '[,]'); + } else { + response = response.replace(/#/g, ','); + response = this.checkResponse(response, 'choice'); + } - ScormWrapper.prototype.recordInteractionMultipleChoice = function(id, response, correct, latency, type) { + const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - if (this.isSCORM2004()) { - response = response.replace(/,|#/g, "[,]"); - } else { - response = response.replace(/#/g, ","); - response = this.checkResponse(response, 'choice'); + scormRecordInteraction.call(this, id, response, correct, latency, type); } - var scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; + recordInteractionMatching(id, response, correct, latency, type) { - scormRecordInteraction.call(this, id, response, correct, latency, type); - }; + response = response.replace(/#/g, ','); + if (this.isSCORM2004()) { + response = response.replace(/,/g, '[,]'); + response = response.replace(/\./g, '[.]'); + } else { + response = this.checkResponse(response, 'matching'); + } - ScormWrapper.prototype.recordInteractionMatching = function(id, response, correct, latency, type) { - - response = response.replace(/#/g, ","); + const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - if(this.isSCORM2004()) { - response = response.replace(/,/g, "[,]"); - response = response.replace(/\./g, "[.]"); - } else { - response = this.checkResponse(response, 'matching'); + scormRecordInteraction.call(this, id, response, correct, latency, type); } - var scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - - scormRecordInteraction.call(this, id, response, correct, latency, type); - }; + recordInteractionFillIn(id, response, correct, latency, type) { + const maxLength = this.isSCORM2004() ? 250 : 255; - ScormWrapper.prototype.recordInteractionFillIn = function(id, response, correct, latency, type) { + if (response.length > maxLength) { + response = response.substr(0, maxLength); - var maxLength = this.isSCORM2004() ? 250 : 255; + this.logger.warn(`ScormWrapper::recordInteractionFillIn: response data for ${id} is longer than the maximum allowed length of ${maxLength} characters; data will be truncated to avoid an error.`); + } - if(response.length > maxLength) { - response = response.substr(0, maxLength); + const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - this.logger.warn("ScormWrapper::recordInteractionFillIn: response data for " + id + " is longer than the maximum allowed length of " + maxLength + " characters; data will be truncated to avoid an error."); + scormRecordInteraction.call(this, id, response, correct, latency, type); } - var scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - - scormRecordInteraction.call(this, id, response, correct, latency, type); - }; - - ScormWrapper.prototype.showDebugWindow = function() { + showDebugWindow() { - if (this.logOutputWin && !this.logOutputWin.closed) { - this.logOutputWin.close(); - } + if (this.logOutputWin && !this.logOutputWin.closed) { + this.logOutputWin.close(); + } - this.logOutputWin = window.open("log_output.html", "Log", "width=600,height=300,status=no,scrollbars=yes,resizable=yes,menubar=yes,toolbar=yes,location=yes,top=0,left=0"); + this.logOutputWin = window.open('log_output.html', 'Log', 'width=600,height=300,status=no,scrollbars=yes,resizable=yes,menubar=yes,toolbar=yes,location=yes,top=0,left=0'); - if (this.logOutputWin) + if (this.logOutputWin) { this.logOutputWin.focus(); + } - return; - }; + } - ScormWrapper.prototype.convertToSCORM12Time = function(msConvert) { + convertToSCORM12Time(msConvert) { - var msPerSec = 1000; - var msPerMin = msPerSec * 60; - var msPerHour = msPerMin * 60; + const msPerSec = 1000; + const msPerMin = msPerSec * 60; + const msPerHour = msPerMin * 60; - var ms = msConvert % msPerSec; - msConvert = msConvert - ms; + const ms = msConvert % msPerSec; + msConvert = msConvert - ms; - var secs = msConvert % msPerMin; - msConvert = msConvert - secs; - secs = secs / msPerSec; + let secs = msConvert % msPerMin; + msConvert = msConvert - secs; + secs = secs / msPerSec; - var mins = msConvert % msPerHour; - msConvert = msConvert - mins; - mins = mins / msPerMin; + let mins = msConvert % msPerHour; + msConvert = msConvert - mins; + mins = mins / msPerMin; - var hrs = msConvert / msPerHour; + const hrs = msConvert / msPerHour; - if (hrs > 9999) { - return "9999:99:99.99"; - } - else { - var str = [this.padWithZeroes(hrs,4), this.padWithZeroes(mins, 2), this.padWithZeroes(secs, 2)].join(":"); - return (str + '.' + Math.floor(ms/10)); + if (hrs > 9999) { + return '9999:99:99.99'; + } + + const str = [ this.padWithZeroes(hrs, 4), this.padWithZeroes(mins, 2), this.padWithZeroes(secs, 2) ].join(':'); + return (`${str}.${Math.floor(ms / 10)}`); } - }; - /** + /** * Converts milliseconds into the SCORM 2004 data type 'timeinterval (second, 10,2)' * this will output something like 'P1DT3H5M0S' which indicates a period of time of 1 day, 3 hours and 5 minutes * or 'PT2M10.1S' which indicates a period of time of 2 minutes and 10.1 seconds */ - ScormWrapper.prototype.convertToSCORM2004Time = function(msConvert) { - var csConvert = Math.floor(msConvert / 10); - var csPerSec = 100; - var csPerMin = csPerSec * 60; - var csPerHour = csPerMin * 60; - var csPerDay = csPerHour * 24; + convertToSCORM2004Time(msConvert) { + let csConvert = Math.floor(msConvert / 10); + const csPerSec = 100; + const csPerMin = csPerSec * 60; + const csPerHour = csPerMin * 60; + const csPerDay = csPerHour * 24; - var days = Math.floor(csConvert/ csPerDay); - csConvert -= days * csPerDay; - days = days ? days + "D" : ""; + let days = Math.floor(csConvert / csPerDay); + csConvert -= days * csPerDay; + days = days ? days + 'D' : ''; - var hours = Math.floor(csConvert/ csPerHour); - csConvert -= hours * csPerHour; - hours = hours ? hours + "H" : ""; + let hours = Math.floor(csConvert / csPerHour); + csConvert -= hours * csPerHour; + hours = hours ? hours + 'H' : ''; - var mins = Math.floor(csConvert/ csPerMin); - csConvert -= mins * csPerMin; - mins = mins ? mins + "M" : ""; + let mins = Math.floor(csConvert / csPerMin); + csConvert -= mins * csPerMin; + mins = mins ? mins + 'M' : ''; - var secs = Math.floor(csConvert/ csPerSec); - csConvert -= secs * csPerSec; - secs = secs ? secs : "0"; + let secs = Math.floor(csConvert / csPerSec); + csConvert -= secs * csPerSec; + secs = secs || '0'; - var cs = csConvert; - cs = cs ? "." + cs : ""; + let cs = csConvert; + cs = cs ? '.' + cs : ''; - var seconds = secs + cs + "S"; + const seconds = secs + cs + 'S'; - var hms = [hours,mins,seconds].join(""); + const hms = [ hours, mins, seconds ].join(''); - return "P" + days + "T" + hms; - }; + return 'P' + days + 'T' + hms; + } - ScormWrapper.prototype.getCMITime = function() { + getCMITime() { - var date = new Date(); + const date = new Date(); - var hours = this.padWithZeroes(date.getHours(),2); - var min = this.padWithZeroes(date.getMinutes(),2); - var sec = this.padWithZeroes(date.getSeconds(),2); + const hours = this.padWithZeroes(date.getHours(), 2); + const min = this.padWithZeroes(date.getMinutes(), 2); + const sec = this.padWithZeroes(date.getSeconds(), 2); - return [hours, min, sec].join(":"); - }; + return [ hours, min, sec ].join(':'); + } - /** + /** * returns the current date & time in the format YYYY-MM-DDTHH:mm:ss */ - ScormWrapper.prototype.getISO8601Timestamp = function() { - var date = new Date().toISOString(); - return date.replace(/.\d\d\dZ/, "");//Date.toISOString returns the date in the format YYYY-MM-DDTHH:mm:ss.sssZ so we need to drop the last bit to make it SCORM 2004 conformant - }; + getISO8601Timestamp() { + const date = new Date().toISOString(); + return date.replace(/.\d\d\dZ/, ''); // Date.toISOString returns the date in the format YYYY-MM-DDTHH:mm:ss.sssZ so we need to drop the last bit to make it SCORM 2004 conformant + } - ScormWrapper.prototype.padWithZeroes = function(numToPad, padBy) { + padWithZeroes(numToPad, padBy) { - var len = padBy; + let len = padBy; - while(--len){ numToPad = "0" + numToPad; } + while (--len) { + numToPad = '0' + numToPad; + } - return numToPad.slice(-padBy); - }; + return numToPad.slice(-padBy); + } - ScormWrapper.prototype.trim = function(str) { - return str.replace(/^\s*|\s*$/g, ""); - }; + trim(str) { + return str.replace(/^\s*|\s*$/g, ''); + } - ScormWrapper.prototype.isSCORM2004 = function() { - return this.scorm.version === "2004"; - }; + isSCORM2004() { + return this.scorm.version === '2004'; + } - /* + /* * SCORM 1.2 requires that the identifiers in cmi.interactions.n.student_response for choice and matching activities be a character from [0-9a-z]. * When numeric identifiers are used this function attempts to map identifiers 10 to 35 to [a-z]. Resolves issues/1376. */ - ScormWrapper.prototype.checkResponse = function(response, responseType) { - if (!response) return response; - if (responseType != 'choice' && responseType != 'matching') return response; + checkResponse(response, responseType) { + if (!response) return response; + if (responseType !== 'choice' && responseType !== 'matching') return response; - response = response.split(/,|#/); + response = response.split(/,|#/); - var self = this; + const self = this; - if (responseType == 'choice') { - response = response.map(checkIdentifier); - } else { - response = response.map(function(r) { - var identifiers = r.split('.'); - return checkIdentifier(identifiers[0]) + '.' + checkIdentifier(identifiers[1]); - }); - } + if (responseType === 'choice') { + response = response.map(checkIdentifier); + } else { + response = response.map(r => { + const identifiers = r.split('.'); + return checkIdentifier(identifiers[0]) + '.' + checkIdentifier(identifiers[1]); + }); + } - function checkIdentifier(r) { - var i; + function checkIdentifier(r) { + let i; - // if [0-9] then ok - if (r.length == 1 && r >= '0' && r <= '9') return r; + // if [0-9] then ok + if (r.length === 1 && r >= '0' && r <= '9') return r; - // if [a-z] then ok - if (r.length == 1 && r >= 'a' && r <= 'z') return r; + // if [a-z] then ok + if (r.length === 1 && r >= 'a' && r <= 'z') return r; - // try to map integers 10-35 to [a-z] - i = parseInt(r); + // try to map integers 10-35 to [a-z] + i = parseInt(r); + + if (isNaN(i) || i < 10 || i > 35) { + self.handleError('Numeric choice/matching response elements must use a value from 0 to 35 in SCORM 1.2'); + } - if (isNaN(i) || i < 10 || i > 35) { - self.handleError('Numeric choice/matching response elements must use a value from 0 to 35 in SCORM 1.2'); + return Number(i).toString(36); // 10 maps to 'a', 11 maps to 'b', ..., 35 maps to 'z' } - return Number(i).toString(36); // 10 maps to 'a', 11 maps to 'b', ..., 35 maps to 'z' + return response.join(','); } - return response.join(','); - }; + getExitState() { + const completionStatus = this.scorm.data.completionStatus; + const isIncomplete = completionStatus === 'incomplete' || completionStatus === 'not attempted'; + const exitState = isIncomplete ? this.exitStateIfIncomplete : this.exitStateIfComplete; - ScormWrapper.prototype.getExitState = function() { - var completionStatus = this.scorm.data.completionStatus; - var isIncomplete = completionStatus === 'incomplete' || completionStatus === 'not attempted'; - var exitState = isIncomplete ? this.exitStateIfIncomplete : this.exitStateIfComplete; + if (exitState !== 'auto') return exitState; - if (exitState !== 'auto') return exitState; + if (this.isSCORM2004()) return (isIncomplete ? 'suspend' : 'normal'); - if (this.isSCORM2004()) return (isIncomplete ? 'suspend' : 'normal'); + return ''; + } + + } - return ''; - }; + // static + ScormWrapper.instance = null; return ScormWrapper; diff --git a/js/serializers/ComponentSerializer.js b/js/serializers/ComponentSerializer.js new file mode 100644 index 00000000..8459d9ec --- /dev/null +++ b/js/serializers/ComponentSerializer.js @@ -0,0 +1,143 @@ +define([ + 'core/js/adapt', + './SCORMSuspendData' +], function (Adapt, SCORMSuspendData) { + + class ComponentSerializer extends Backbone.Controller { + + initialize(trackingIdType) { + this.trackingIdType = trackingIdType; + } + + serialize() { + const states = []; + Adapt.data.each(model => { + if (model.get('_type') !== this.trackingIdType) { + return; + } + const trackingId = model.get('_trackingId'); + if (typeof trackingId === 'undefined') { + return; + } + const isContainer = model.hasManagedChildren; + const components = isContainer ? + model.findDescendantModels('component') : + [model]; + components.forEach((component, index) => { + let modelState = null; + if (!component.getAttemptState) { + // Legacy components without getAttemptState + modelState = component.get('_isQuestionType') ? + [ + [ + component.get('_score') || 0, + component.get('_attemptsLeft') || 0 + ], + [ + component.get('_isComplete') || false, + component.get('_isInteractionComplete') || false, + component.get('_isSubmitted') || false, + component.get('_isCorrect') || false + ], + [ + component.get('_userAnswer') + ] + ] : + [ + [], + [ + component.get('_isComplete') || false, + component.get('_isInteractionComplete') || false + ], + [ + component.get('_userAnswer') + ] + ]; + } else { + modelState = component.getAttemptState(); + } + // correct the useranswer array as it is sometimes not an array + // incomplete components are undefined and slider is a number + const userAnswer = modelState[2][0]; + const hasUserAnswer = typeof userAnswer !== 'undefined' && userAnswer !== null; + const isUserAnswerArray = Array.isArray(userAnswer); + if (!hasUserAnswer) { + modelState[2][0] = []; + } else if (!isUserAnswerArray) { + modelState[2][0] = [modelState[2][0]]; + } + // attemptstates is empty if not a question or not attempted + const attemptStates = modelState[2][0]; + const hasAttemptStates = !Array.isArray(attemptStates); + if (!hasAttemptStates) { + modelState[2][1] = []; + } + // create the restoration state object + const state = [ + [ trackingId, index ], + [ hasUserAnswer, isUserAnswerArray, hasAttemptStates ], + modelState + ]; + states.push(state); + }); + }); + return SCORMSuspendData.serialize(states); + } + + deserialize(binary) { + const trackingIdMap = Adapt.data.toArray().reduce((trackingIdMap, model) => { + const trackingId = model.get('_trackingId'); + if (typeof trackingId === 'undefined') return trackingIdMap; + trackingIdMap[trackingId] = model; + return trackingIdMap; + }, {}); + const states = SCORMSuspendData.deserialize(binary); + states.forEach(state => { + const [ trackingId, index ] = state[0]; + const [ hasUserAnswer, isUserAnswerArray, hasAttemptStates ] = state[1]; + const modelState = state[2]; + // correct useranswer + if (!hasUserAnswer) { + modelState[2][0] = null; + } else if (!isUserAnswerArray) { + modelState[2][0] = modelState[2][0][0]; + } + // allow empty attemptstates + if (!hasAttemptStates) { + modelState[2][1] = null; + } + const model = trackingIdMap[trackingId]; + const isContainer = model.hasManagedChildren; + const components = isContainer ? + model.findDescendantModels('component') : + [model]; + const component = components[index]; + if (component.setAttemptObject) { + const attemptObject = component.getAttemptObject(modelState); + component.setAttemptObject(attemptObject, false); + return; + } + // Legacy components without getAttemptState + component.get('_isQuestionType') ? + component.set({ + _score: modelState[0][0], + _attemptsLeft: modelState[0][1], + _isComplete: modelState[1][0], + _isInteractionComplete: modelState[1][1], + _isSubmitted: modelState[1][2], + _isCorrect: modelState[1][3], + _userAnswer: modelState[2][0] + }) : + component.set({ + _isComplete: modelState[1][0], + _isInteractionComplete: modelState[1][1], + _userAnswer: modelState[2][0] + }); + }); + } + + } + + return ComponentSerializer; + +}); diff --git a/js/serializers/scormSuspendDataSerializer.js b/js/serializers/SCORMSuspendData.js similarity index 99% rename from js/serializers/scormSuspendDataSerializer.js rename to js/serializers/SCORMSuspendData.js index e5b5a5cc..9dffa192 100644 --- a/js/serializers/scormSuspendDataSerializer.js +++ b/js/serializers/SCORMSuspendData.js @@ -1,4 +1,6 @@ -(function() { +define([ + 'underscore' +], function(_) { /** * 2020/05/06 SCORMSuspendData * @@ -1120,5 +1122,5 @@ } - window.SCORMSuspendData = new Converter(); -})(); + return (window.SCORMSuspendData = new Converter()); +}); diff --git a/js/serializers/default.js b/js/serializers/default.js deleted file mode 100644 index 12c452e0..00000000 --- a/js/serializers/default.js +++ /dev/null @@ -1,95 +0,0 @@ -define([ - 'core/js/adapt' -], function (Adapt) { - - //Captures the completion status of the blocks - //Returns and parses a '1010101' style string - - var serializer = { - serialize: function () { - return this.serializeSaveState('_isComplete'); - }, - - serializeSaveState: function(attribute) { - if (Adapt.course.get('_latestTrackingId') === undefined) { - var message = "This course is missing a latestTrackingID.\n\nPlease run the grunt process prior to deploying this module on LMS.\n\nScorm tracking will not work correctly until this is done."; - console.error(message); - } - - var excludeAssessments = Adapt.config.get('_spoor') && Adapt.config.get('_spoor')._tracking && Adapt.config.get('_spoor')._tracking._excludeAssessments; - - // create the array to be serialised, pre-populated with dashes that represent unused tracking ids - because we'll never re-use a tracking id in the same course - var data = []; - var length = Adapt.course.get('_latestTrackingId') + 1; - for (var i = 0; i < length; i++) { - data[i] = "-"; - } - - // now go through all the blocks, replacing the appropriate dashes with 0 (incomplete) or 1 (completed) for each of the blocks - _.each(Adapt.blocks.models, function(model, index) { - var _trackingId = model.get('_trackingId'), - isPartOfAssessment = model.getParent().get('_assessment'), - state = model.get(attribute) ? 1: 0; - - if (excludeAssessments && isPartOfAssessment) { - state = 0; - } - - if (_trackingId === undefined) { - var message = "Block '" + model.get('_id') + "' doesn't have a tracking ID assigned.\n\nPlease run the grunt process prior to deploying this module on LMS.\n\nScorm tracking will not work correctly until this is done."; - console.error(message); - } else { - data[_trackingId] = state; - } - }, this); - - return data.join(""); - }, - - deserialize: function (completion, callback) { - var syncIterations = 1; // number of synchronous iterations to perform - var i = 0, arr = this.deserializeSaveState(completion), len = arr.length; - - function step() { - var state; - for (var j=0, count=Math.min(syncIterations, len-i); j < count; i++, j++) { - state = arr[i]; - if (state === 1) { - markBlockAsComplete(Adapt.blocks.findWhere({_trackingId: i})); - } - } - i == len ? callback() : setTimeout(step); - } - - function markBlockAsComplete(block) { - if (!block) { - return; - } - - block.getChildren().each(function(child) { - child.set('_isComplete', true); - }); - } - - step(); - }, - - deserializeSaveState: function (string) { - var completionArray = string.split(""); - - for (var i = 0; i < completionArray.length; i++) { - if (completionArray[i] === "-") { - completionArray[i] = -1; - } else { - completionArray[i] = parseInt(completionArray[i], 10); - } - } - - return completionArray; - } - - }; - - return serializer; - -}); diff --git a/js/serializers/questions.js b/js/serializers/questions.js deleted file mode 100644 index 5ac97a2c..00000000 --- a/js/serializers/questions.js +++ /dev/null @@ -1,215 +0,0 @@ -define([ - 'core/js/adapt', - './scormSuspendDataSerializer' -], function (Adapt) { - - //Captures the completion status and user selections of the question components - //Returns and parses a base64 style string - var includes = { - '_isQuestionType': true - }; - - var serializer = { - serialize: function () { - return this.serializeSaveState(); - }, - - serializeSaveState: function() { - if (Adapt.course.get('_latestTrackingId') === undefined) { - var message = "This course is missing a latestTrackingID.\n\nPlease run the grunt process prior to deploying this module on LMS.\n\nScorm tracking will not work correctly until this is done."; - console.error(message); - return ""; - } - - var rtn = ""; - try { - var data = this.captureData(); - if (data.length === 0) return ""; - rtn = SCORMSuspendData.serialize(data); - } catch(e) { - console.error(e); - } - - return rtn; - }, - - captureData: function() { - var data = []; - - var trackingIds = Adapt.blocks.pluck('_trackingId').filter(id => (Number.isInteger(id) && id >= 0)); - var blocks = {}; - var countInBlock = {}; - var config = Adapt.config.get('_spoor'); - var shouldStoreAttempts = config && config._tracking && config._tracking._shouldStoreAttempts; - - for (var i = 0, l = trackingIds.length; i < l; i++) { - - var trackingId = trackingIds[i]; - var blockModel = Adapt.blocks.findWhere({_trackingId: trackingId }); - var componentModels = blockModel.getChildren().where(includes); - - for (var c = 0, cl = componentModels.length; c < cl; c++) { - - var component = componentModels[c].toJSON(); - var blockId = component._parentId; - - if (!blocks[blockId]) { - blocks[blockId] = blockModel.toJSON(); - } - - var block = blocks[blockId]; - if (countInBlock[blockId] === undefined) countInBlock[blockId] = -1; - countInBlock[blockId]++; - - var blockLocation = countInBlock[blockId]; - - var hasUserAnswer = (component._userAnswer !== undefined); - var isUserAnswerArray = Array.isArray(component._userAnswer); - - if (hasUserAnswer && isUserAnswerArray && component._userAnswer.length === 0) { - hasUserAnswer = false; - isUserAnswerArray = false; - } - - var hasAttemptStates = (component._attemptStates !== undefined); - var isAttemptStatesArray = Array.isArray(component._attemptStates); - if (hasAttemptStates && isAttemptStatesArray && component._attemptStates.length === 0) { - hasAttemptStates = false; - isAttemptStatesArray = false; - } - - var numericParameters = [ - blockLocation, - block._trackingId, - component._score || 0, - component._attemptsLeft || 0 - ]; - - var booleanParameters = [ - hasUserAnswer, - isUserAnswerArray, - hasAttemptStates, - isAttemptStatesArray, - component._isComplete, - component._isInteractionComplete, - component._isSubmitted, - component._isCorrect || false - ]; - - var dataItem = [ - numericParameters, - booleanParameters - ]; - - var invalidError; - if (hasUserAnswer) { - var userAnswer = isUserAnswerArray ? component._userAnswer : [component._userAnswer]; - - invalidError = SCORMSuspendData.getInvalidTypeError(userAnswer); - - if (invalidError) { - console.log("Cannot store _userAnswers from component " + component._id + " as array is invalid", invalidError); - continue; - } - - dataItem.push(userAnswer); - } else { - dataItem.push([]); - } - - if (shouldStoreAttempts && hasAttemptStates) { - var attemptStates = isAttemptStatesArray ? component._attemptStates : [component_attemptStates]; - - invalidError = SCORMSuspendData.getInvalidTypeError(userAnswer); - - if (invalidError) { - console.log(`Cannot store _attemptStates from component ${component._id} as array is invalid`, invalidError); - continue; - } - - dataItem.push(attemptStates); - } else { - dataItem.push([]); - } - - data.push(dataItem); - - } - - } - - return data; - - }, - - deserialize: function (str) { - - try { - var data = SCORMSuspendData.deserialize(str); - this.releaseData( data ); - } catch(e) { - console.error(e); - } - - }, - - releaseData: function (arr) { - - var config = Adapt.config.get('_spoor'); - var shouldStoreAttempts = config && config._tracking && config._tracking._shouldStoreAttempts; - - for (var i = 0, l = arr.length; i < l; i++) { - var dataItem = arr[i]; - - var numericParameters = dataItem[0]; - var booleanParameters = dataItem[1]; - - var blockLocation = numericParameters[0]; - var trackingId = numericParameters[1]; - var _score = numericParameters[2]; - var _attemptsLeft = numericParameters[3] || 0; - - var hasUserAnswer = booleanParameters[0]; - var isUserAnswerArray = booleanParameters[1]; - var hasAttemptStates = booleanParameters[2]; - var isAttemptStatesArray = booleanParameters[3]; - var _isComplete = booleanParameters[4]; - var _isInteractionComplete = booleanParameters[5]; - var _isSubmitted = booleanParameters[6]; - var _isCorrect = booleanParameters[7]; - - var block = Adapt.blocks.findWhere({_trackingId: trackingId}); - var components = block.getChildren(); - components = components.where(includes); - var component = components[blockLocation]; - - component.set({ - _isComplete, - _isInteractionComplete, - _isSubmitted, - _score, - _isCorrect, - _attemptsLeft - }); - - if (hasUserAnswer) { - var userAnswer = dataItem[2]; - if (!isUserAnswerArray) userAnswer = userAnswer[0]; - - component.set("_userAnswer", userAnswer); - } - - if (shouldStoreAttempts && hasAttemptStates) { - var attemptStates = dataItem[3]; - if (!isAttemptStatesArray) attemptStates = attemptStates[0]; - - component.set('_attemptStates', attemptStates); - } - - } - } - }; - - return serializer; - -});