diff --git a/README.md b/README.md index 128127cf..ad39658c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # adapt-contrib-spoor -**Spoor** is an *extension* bundled with the [Adapt framework](https://github.com/adaptlearning/adapt_framework). +**Spoor** is an *extension* bundled with the [Adapt framework](https://github.com/adaptlearning/adapt_framework). This extension provides course tracking functionality (hence the name [spoor](https://en.wikipedia.org/wiki/Spoor_(animal))) via [SCORM](https://en.wikipedia.org/wiki/Sharable_Content_Object_Reference_Model) standards for compliant [Learning Management Systems (LMS)](https://en.wikipedia.org/wiki/Learning_management_system). As default, only SCORM 1.2 or SCORM 2004 4th Edition files are included. See [_scormVersion](https://github.com/adaptlearning/adapt-contrib-spoor#_scormVersion) for details on how to configure this accordingly. @@ -202,4 +202,4 @@ Currently (officially) only supports SCORM 1.2 **Author / maintainer:** Adapt Core Team with [contributors](https://github.com/adaptlearning/adapt-contrib-spoor/graphs/contributors) **Accessibility support:** n/a **RTL support:** n/a -**Cross-platform coverage:** Chrome, Chrome for Android, Firefox (ESR + latest version), Edge, IE11, Safari 14 for macOS/iOS/iPadOS, Opera +**Cross-platform coverage:** Chrome, Chrome for Android, Firefox (ESR + latest version), Edge, Safari 14 for macOS/iOS/iPadOS, Opera diff --git a/bower.json b/bower.json index bcc4a252..457bf46a 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "adapt-contrib-spoor", "version": "5.8.2", - "framework": ">=5.28.1", + "framework": ">=5.31.31", "homepage": "https://github.com/adaptlearning/adapt-contrib-spoor", "bugs": "https://github.com/adaptlearning/adapt-contrib-spoor/issues", "extension": "spoor", diff --git a/js/adapt-offlineStorage-scorm.js b/js/adapt-offlineStorage-scorm.js index a4c74915..b09bd51a 100644 --- a/js/adapt-offlineStorage-scorm.js +++ b/js/adapt-offlineStorage-scorm.js @@ -131,6 +131,12 @@ export default class OfflineStorageScorm extends Backbone.Controller { switch (name.toLowerCase()) { case 'interaction': return this.scorm.recordInteraction(...args); + case 'objectivedescription': + return this.scorm.recordObjectiveDescription(...args); + case 'objectivestatus': + return this.scorm.recordObjectiveStatus(...args); + case 'objectivescore': + return this.scorm.recordObjectiveScore(...args); case 'location': return this.scorm.setLessonLocation(...args); case 'score': diff --git a/js/adapt-stateful-session.js b/js/adapt-stateful-session.js index 38d1b492..5cfa142a 100644 --- a/js/adapt-stateful-session.js +++ b/js/adapt-stateful-session.js @@ -24,7 +24,10 @@ export default class StatefulSession extends Backbone.Controller { } beginSession() { - this.listenTo(Adapt, 'app:dataReady', this.restoreSession); + this.listenTo(Adapt, { + 'app:dataReady': this.restoreSession, + 'adapt:start': this.onAdaptStart + }); this._trackingIdType = Adapt.build.get('trackingIdType') || 'block'; // suppress SCORM errors if 'nolmserrors' is found in the querystring if (window.location.search.indexOf('nolmserrors') !== -1) { @@ -59,8 +62,6 @@ export default class StatefulSession extends Backbone.Controller { restoreSession() { this.setupLearnerInfo(); this.restoreSessionState(); - // defer call because AdaptModel.check*Status functions are asynchronous - _.defer(this.setupEventListeners.bind(this)); } setupLearnerInfo() { @@ -93,13 +94,16 @@ export default class StatefulSession extends Backbone.Controller { setupEventListeners() { this.removeEventListeners(); this.listenTo(Adapt.components, 'change:_isComplete', this.debouncedSaveSession); + this.listenTo(Adapt.contentObjects, 'change:_isComplete', this.onContentObjectCompleteChange); this.listenTo(Adapt.course, 'change:_isComplete', this.debouncedSaveSession); if (this._shouldStoreResponses) { this.listenTo(data, 'change:_isSubmitted change:_userAnswer', this.debouncedSaveSession); } this.listenTo(Adapt, { 'app:dataReady': this.restoreSession, + 'adapt:start': this.onAdaptStart, 'app:languageChanged': this.onLanguageChanged, + 'pageView:ready': this.onPageViewReady, 'questionView:recordInteraction': this.onQuestionRecordInteraction, 'tracking:complete': this.onTrackingComplete }); @@ -161,16 +165,43 @@ export default class StatefulSession extends Backbone.Controller { logging.info(`course._isComplete: ${courseComplete}, course._isAssessmentPassed: ${assessmentPassed}, ${this._trackingIdType} completion: ${completionString}`); } + initializeContentObjectives() { + Adapt.contentObjects.forEach(model => { + if (model.isTypeGroup('course')) return; + const id = model.get('_id'); + const description = model.get('title') || model.get('displayTitle'); + offlineStorage.set('objectiveDescription', id, description); + if (model.get('_isVisited')) return; + const completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase; + offlineStorage.set('objectiveStatus', id, completionStatus); + }); + } + + onAdaptStart() { + this.setupEventListeners(); + this.initializeContentObjectives(); + } + onLanguageChanged() { + this.stopListening(Adapt.contentObjects, 'change:_isComplete', this.onContentObjectCompleteChange); const config = Adapt.spoor.config; if (config?._reporting?._resetStatusOnLanguageChange !== true) return; - offlineStorage.set('status', 'incomplete'); + const completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase; + offlineStorage.set('status', completionStatus); } onVisibilityChange() { if (document.visibilityState === 'hidden') this.scorm.commit(); } + onPageViewReady(view) { + const model = view.model; + if (model.get('_isComplete')) return; + const id = model.get('_id'); + const completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase; + offlineStorage.set('objectiveStatus', id, completionStatus); + } + onQuestionRecordInteraction(questionView) { if (!this._shouldRecordInteractions) return; // View functions are deprecated: getResponseType, getResponse, isCorrect, getLatency @@ -188,6 +219,13 @@ export default class StatefulSession extends Backbone.Controller { offlineStorage.set('interaction', id, response, result, latency, responseType); } + onContentObjectCompleteChange(model) { + if (model.isTypeGroup('course')) return; + const id = model.get('_id'); + const completionStatus = (model.get('_isComplete') ? COMPLETION_STATE.COMPLETED : COMPLETION_STATE.INCOMPLETE).asLowerCase; + offlineStorage.set('objectiveStatus', id, completionStatus); + } + onTrackingComplete(completionData) { const config = Adapt.spoor.config; this.saveSessionState(); diff --git a/js/enums/completionStateEnum.js b/js/enums/completionStateEnum.js new file mode 100644 index 00000000..5a50fcae --- /dev/null +++ b/js/enums/completionStateEnum.js @@ -0,0 +1,10 @@ +const COMPLETION_STATE = ENUM([ + 'UNKNOWN', + ['NOT ATTEMPTED', 'NOTATTEMPTED'], + 'NOT_ATTEMPTED', + 'BROWSED', + 'INCOMPLETE', + 'COMPLETED' +]); + +export default COMPLETION_STATE; diff --git a/js/enums/successStateEnum.js b/js/enums/successStateEnum.js new file mode 100644 index 00000000..93e1d53d --- /dev/null +++ b/js/enums/successStateEnum.js @@ -0,0 +1,7 @@ +const SUCCESS_STATUS = ENUM([ + 'UNKNOWN', + 'PASSED', + 'FAILED' +]); + +export default SUCCESS_STATUS; diff --git a/js/scorm/cookieLMS.js b/js/scorm/cookieLMS.js index 5bb3facc..04f19e69 100644 --- a/js/scorm/cookieLMS.js +++ b/js/scorm/cookieLMS.js @@ -135,6 +135,7 @@ export function start () { configure(); this.initialize({ 'cmi.interactions': [], + 'cmi.objectives': [], 'cmi.core.lesson_status': 'not attempted', 'cmi.suspend_data': '', 'cmi.core.student_name': 'Surname, Sam', @@ -199,6 +200,7 @@ export function start () { configure(); this.initialize({ 'cmi.interactions': [], + 'cmi.objectives': [], 'cmi.completion_status': 'not attempted', 'cmi.suspend_data': '', 'cmi.learner_name': 'Surname, Sam', diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index 818cb8ff..5821aff9 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -2,6 +2,8 @@ import Adapt from 'core/js/adapt'; import Data from 'core/js/data'; import Wait from 'core/js/wait'; import Notify from 'core/js/notify'; +import COMPLETION_STATE from '../enums/completionStateEnum'; +import SUCCESS_STATE from '../enums/successStateEnum'; import pipwerks from 'libraries/SCORM_API_wrapper'; import Logger from './logger'; import ScormError from './error'; @@ -178,73 +180,54 @@ class ScormWrapper { } setIncomplete() { - this.setValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status', 'incomplete'); - + this.setValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status', COMPLETION_STATE.INCOMPLETE.asLowerCase); if (this.commitOnStatusChange && !this.commitOnAnyChange) this.commit(); } setCompleted() { - this.setValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status', 'completed'); - + this.setValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status', COMPLETION_STATE.COMPLETED.asLowerCase); if (this.commitOnStatusChange && !this.commitOnAnyChange) this.commit(); } setPassed() { if (this.isSCORM2004()) { - this.setValue('cmi.completion_status', 'completed'); - this.setValue('cmi.success_status', 'passed'); + this.setValue('cmi.completion_status', COMPLETION_STATE.COMPLETED.asLowerCase); + this.setValue('cmi.success_status', SUCCESS_STATE.PASSED.asLowerCase); } else { - this.setValue('cmi.core.lesson_status', 'passed'); + this.setValue('cmi.core.lesson_status', SUCCESS_STATE.PASSED.asLowerCase); } - if (this.commitOnStatusChange && !this.commitOnAnyChange) this.commit(); } setFailed() { if (this.isSCORM2004()) { - this.setValue('cmi.success_status', 'failed'); - - if (this.setCompletedWhenFailed) { - this.setValue('cmi.completion_status', 'completed'); - } + this.setValue('cmi.success_status', SUCCESS_STATE.FAILED.asLowerCase); + if (this.setCompletedWhenFailed) this.setValue('cmi.completion_status', COMPLETION_STATE.COMPLETED.asLowerCase); } else { - this.setValue('cmi.core.lesson_status', 'failed'); + this.setValue('cmi.core.lesson_status', SUCCESS_STATE.FAILED.asLowerCase); } - if (this.commitOnStatusChange && !this.commitOnAnyChange) this.commit(); } 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.handleDataError(new ScormError(SERVER_STATUS_UNSUPPORTED, { status })); - return null; - } + if (this.isValidCompletionStatus(status)) return status; + this.handleDataError(new ScormError(SERVER_STATUS_UNSUPPORTED, { status })); + return null; } setStatus(status) { switch (status.toLowerCase()) { - case 'incomplete': + case COMPLETION_STATE.INCOMPLETE.asLowerCase: this.setIncomplete(); break; - case 'completed': + case COMPLETION_STATE.COMPLETED.asLowerCase: this.setCompleted(); break; - case 'passed': + case SUCCESS_STATE.PASSED.asLowerCase: this.setPassed(); break; - case 'failed': + case SUCCESS_STATE.FAILED.asLowerCase: this.setFailed(); break; default: @@ -257,36 +240,8 @@ class ScormWrapper { } setScore(score, minScore = 0, maxScore = 100, isPercentageBased = true) { - if (this.isSCORM2004()) { - // `raw`, `min`, `max` sum absolute values assigned to questions - this.setValue('cmi.score.raw', score); - this.setValue('cmi.score.min', minScore); - this.setValue('cmi.score.max', maxScore); - // range split into negative/positive ranges (rather than minScore-maxScore) depending on score - const range = (score < 0) ? Math.abs(minScore) : maxScore; - // `scaled` converted to -1-1 range to indicate negative/positive weighting now that negative values can be assigned to questions - const scaledScore = score / range; - this.setValue('cmi.score.scaled', scaledScore.toFixed(7)); - return; - } - if (isPercentageBased) { - // convert values to 0-100 range - // negative scores are capped to 0 due to SCORM 1.2 limitations - score = (score < 0) ? 0 : Math.round((score / maxScore) * 100); - minScore = 0; - maxScore = 100; - } else { - const validate = (attribute, value) => { - const isValid = value >= 0 && score <= 100; - if (!isValid) this.logger.warn(`${attribute} must be between 0-100.`); - } - validate('cmi.core.score.raw', score); - validate('cmi.core.score.min', minScore); - validate('cmi.core.score.max', maxScore); - } - 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); + const cmiPrefix = this.isSCORM2004() ? 'cmi' : 'cmi.core'; + this.recordScore(cmiPrefix, ...arguments); } getLessonLocation() { @@ -619,6 +574,33 @@ class ScormWrapper { } + recordScore(cmiPrefix, score, minScore = 0, maxScore = 100, isPercentageBased = true) { + if (this.isSCORM2004()) { + // range split into negative/positive ranges (rather than minScore-maxScore) depending on score + const range = (score < 0) ? Math.abs(minScore) : maxScore; + // `scaled` converted to -1-1 range to indicate negative/positive weighting now that negative values can be assigned to questions + const scaledScore = score / range; + this.setValue(`${cmiPrefix}.score.scaled`, parseFloat(scaledScore.toFixed(7))); + } else if (isPercentageBased) { + // convert values to 0-100 range + // negative scores are capped to 0 due to SCORM 1.2 limitations + score = (score < 0) ? 0 : Math.round((score / maxScore) * 100); + minScore = 0; + maxScore = 100; + } else { + const validate = (attribute, value) => { + const isValid = value >= 0 && score <= 100; + if (!isValid) this.logger.warn(`${attribute} must be between 0-100.`); + } + validate(`${cmiPrefix}.score.raw`, score); + validate(`${cmiPrefix}.score.min`, minScore); + validate(`${cmiPrefix}.score.max`, maxScore); + } + this.setValue(`${cmiPrefix}.score.raw`, score); + if (this.isSupported(`${cmiPrefix}.score.min`)) this.setValue(`${cmiPrefix}.score.min`, minScore); + if (this.isSupported(`${cmiPrefix}.score.max`)) this.setValue(`${cmiPrefix}.score.max`, maxScore); + } + getInteractionCount() { const count = this.getValue('cmi.interactions._count'); return count === '' ? 0 : count; @@ -698,6 +680,105 @@ class ScormWrapper { scormRecordInteraction.call(this, id, response, correct, latency, type); } + getObjectiveCount() { + const count = this.getValue('cmi.objectives._count'); + return count === '' ? 0 : count; + } + + getObjectiveIndexById(id) { + const count = this.getObjectiveCount(); + for (let i = 0; i < count; i++) { + const storedId = this.getValue(`cmi.objectives.${i}.id`); + if (storedId === id) return i; + } + return count; + } + + recordObjectiveDescription(id, description) { + if (!this.isSCORM2004() || !description) return; + id = id.trim(); + const index = this.getObjectiveIndexById(id); + const cmiPrefix = `cmi.objectives.${index}`; + this.setValue(`${cmiPrefix}.id`, id); + this.setValue(`${cmiPrefix}.description`, description); + } + + recordObjectiveScore(id, score, minScore = 0, maxScore = 100, isPercentageBased = true) { + if (!this.isSupported('cmi.objectives._count')) { + this.logger.info('ScormWrapper::recordObjectiveScore: cmi.objectives are not supported by this LMS...'); + return; + } + id = id.trim(); + const index = this.getObjectiveIndexById(id); + const cmiPrefix = `cmi.objectives.${index}`; + this.setValue(`${cmiPrefix}.id`, id); + this.recordScore(cmiPrefix, score, minScore, maxScore, isPercentageBased); + } + + recordObjectiveStatus(id, completionStatus, successStatus = SUCCESS_STATE.UNKNOWN.asLowerCase) { + if (!this.isSupported('cmi.objectives._count')) { + this.logger.info('ScormWrapper::recordObjectiveStatus: cmi.objectives are not supported by this LMS...'); + return; + } + if (!this.isValidCompletionStatus(completionStatus)) { + this.handleDataError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { completionStatus })); + return; + } + if (this.isSCORM2004() && !this.isValidSuccessStatus(successStatus)) { + this.handleDataError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { successStatus })); + return; + } + id = id.trim(); + const index = this.getObjectiveIndexById(id); + const cmiPrefix = `cmi.objectives.${index}`; + this.setValue(`${cmiPrefix}.id`, id); + if (this.isSCORM2004()) { + this.setValue(`${cmiPrefix}.completion_status`, completionStatus); + this.setValue(`${cmiPrefix}.success_status`, successStatus); + return; + } + if (completionStatus === COMPLETION_STATE.COMPLETED.asLowerCase && successStatus !== SUCCESS_STATE.UNKNOWN.asLowerCase) completionStatus = successStatus; + this.setValue(`${cmiPrefix}.status`, completionStatus); + } + + isValidCompletionStatus(status) { + status = status.toLowerCase(); // workaround for some LMSs (e.g. Arena) not adhering to the all-lowercase rule + if (this.isSCORM2004()) { + switch(status) { + case COMPLETION_STATE.UNKNOWN.asLowerCase: + case COMPLETION_STATE.NOTATTEMPTED.asLowerCase: + case COMPLETION_STATE.NOT_ATTEMPTED.asLowerCase: // mentioned in SCORM 2004 spec - mapped to 'not attempted' + case COMPLETION_STATE.INCOMPLETE.asLowerCase: + case COMPLETION_STATE.COMPLETED.asLowerCase: + return true; + } + } else { + switch(status) { + case COMPLETION_STATE.NOTATTEMPTED.asLowerCase: + case COMPLETION_STATE.BROWSED.asLowerCase: + case COMPLETION_STATE.INCOMPLETE.asLowerCase: + case COMPLETION_STATE.COMPLETED.asLowerCase: + case SUCCESS_STATE.PASSED.asLowerCase: + case SUCCESS_STATE.FAILED.asLowerCase: + return true; + } + } + return false; + } + + isValidSuccessStatus(status) { + status = status.toLowerCase(); // workaround for some LMSs (e.g. Arena) not adhering to the all-lowercase rule + if (this.isSCORM2004()) { + switch(status) { + case SUCCESS_STATE.UNKNOWN.asLowerCase: + case SUCCESS_STATE.PASSED.asLowerCase: + case SUCCESS_STATE.FAILED.asLowerCase: + return true; + } + } + return false; + } + showDebugWindow() { if (this.logOutputWin && !this.logOutputWin.closed) { @@ -852,13 +933,10 @@ class ScormWrapper { getExitState() { const completionStatus = this.scorm.data.completionStatus; - const isIncomplete = completionStatus === 'incomplete' || completionStatus === 'not attempted'; + const isIncomplete = completionStatus === COMPLETION_STATE.INCOMPLETE.asLowerCase || completionStatus === COMPLETION_STATE.UNKNOWN.asLowerCase; const exitState = isIncomplete ? this.exitStateIfIncomplete : this.exitStateIfComplete; - if (exitState !== 'auto') return exitState; - if (this.isSCORM2004()) return (isIncomplete ? 'suspend' : 'normal'); - return ''; } diff --git a/package.json b/package.json index bcc4a252..457bf46a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapt-contrib-spoor", "version": "5.8.2", - "framework": ">=5.28.1", + "framework": ">=5.31.31", "homepage": "https://github.com/adaptlearning/adapt-contrib-spoor", "bugs": "https://github.com/adaptlearning/adapt-contrib-spoor/issues", "extension": "spoor",