From 0111c1522937e5574697e56709980f0875fa3d15 Mon Sep 17 00:00:00 2001 From: danielghost Date: Wed, 4 Dec 2024 16:42:30 +0000 Subject: [PATCH 1/5] New: expanded use of `cmi.interactions` data elements to include more context for reporting purposes (fixes #281). --- js/adapt-stateful-session.js | 25 ++++++++++++-------- js/scorm/wrapper.js | 46 +++++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/js/adapt-stateful-session.js b/js/adapt-stateful-session.js index 4f5733d..4454139 100644 --- a/js/adapt-stateful-session.js +++ b/js/adapt-stateful-session.js @@ -216,22 +216,27 @@ export default class StatefulSession extends Backbone.Controller { offlineStorage.set('objectiveStatus', id, completionStatus); } - onQuestionRecordInteraction(questionView) { + onQuestionRecordInteraction(view) { if (!this.shouldRecordInteractions) return; if (!this.scorm.isSupported('cmi.interactions._count')) return; - // View functions are deprecated: getResponseType, getResponse, isCorrect, getLatency - const questionModel = questionView.model; - const responseType = (questionModel.getResponseType ? questionModel.getResponseType() : questionView.getResponseType()); + const model = view.model; + const responseType = model.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 modelId = model.get('_id'); const id = this._uniqueInteractionIds - ? `${this.scorm.getInteractionCount()}-${questionModel.get('_id')}` - : questionModel.get('_id'); - const response = (questionModel.getResponse ? questionModel.getResponse() : questionView.getResponse()); - const result = (questionModel.isCorrect ? questionModel.isCorrect() : questionView.isCorrect()); - const latency = (questionModel.getLatency ? questionModel.getLatency() : questionView.getLatency()); - offlineStorage.set('interaction', id, response, result, latency, responseType); + ? `${this.scorm.getInteractionCount()}-${modelId}` + : modelId; + const response = model.getResponse(); + const result = model.isCorrect(); + const latency = model?.getLatency?.() ?? view.getLatency(); + const correctResponsesPattern = model.getInteractionObject()?.correctResponsesPattern; + const objectiveIds = Adapt?.scoring?.getSubsetsByModelId(modelId) + .filter(set => set.type !== 'adapt') + .map(({ id }) => id); + const description = model.get('body'); + offlineStorage.set('interaction', id, response, result, latency, responseType, correctResponsesPattern, objectiveIds, description); } onContentObjectCompleteChange(model) { diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index 7a89552..e66565e 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -362,7 +362,7 @@ class ScormWrapper { })); } - recordInteraction(id, response, correct, latency, type) { + recordInteraction(id, response, correct, latency, type, correctResponsesPattern, description) { if (!this.isChildSupported('cmi.interactions.n.id') || !this.isSupported('cmi.interactions._count')) return; switch (type) { case 'choice': @@ -627,7 +627,7 @@ class ScormWrapper { return count === '' ? 0 : count; } - recordInteractionScorm12(id, response, correct, latency, type) { + recordInteractionScorm12(id, response, correct, latency, type, correctResponsesPattern, objectiveIds) { id = id.trim(); const cmiPrefix = `cmi.interactions.${this.getInteractionCount()}`; this.setValue(`${cmiPrefix}.id`, id); @@ -635,10 +635,20 @@ class ScormWrapper { this.setValueIfChildSupported(`${cmiPrefix}.student_response`, response); this.setValueIfChildSupported(`${cmiPrefix}.result`, correct ? 'correct' : 'wrong'); if (latency !== null && latency !== undefined) this.setValueIfChildSupported(`${cmiPrefix}.latency`, this.convertToSCORM12Time(latency)); + if (this.isChildSupported(`${cmiPrefix}.correct_responses`) && correctResponsesPattern?.length) { + correctResponsesPattern.forEach((response, index) => { + this.setValue(`${cmiPrefix}.correct_responses.${index}.pattern`, response); + }); + } + if (this.isChildSupported(`${cmiPrefix}.objectives`) && objectiveIds?.length) { + objectiveIds.forEach((id, index) => { + this.setValue(`${cmiPrefix}.objectives.${index}.id`, id); + }); + } this.setValueIfChildSupported(`${cmiPrefix}.time`, this.getCMITime()); } - recordInteractionScorm2004(id, response, correct, latency, type) { + recordInteractionScorm2004(id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description) { id = id.trim(); const cmiPrefix = `cmi.interactions.${this.getInteractionCount()}`; this.setValue(`${cmiPrefix}.id`, id); @@ -646,32 +656,50 @@ class ScormWrapper { 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)); + if (correctResponsesPattern?.length) { + correctResponsesPattern.forEach((response, index) => { + this.setValue(`${cmiPrefix}.correct_responses.${index}.pattern`, response); + }); + } + if (objectiveIds?.length) { + objectiveIds.forEach((id, index) => { + this.setValue(`${cmiPrefix}.objectives.${index}.id`, id); + }); + } + if (description) { + const maxLength = 250; + if (description.length > maxLength) description = description.substr(0, maxLength).trim(); + this.setValue(`${cmiPrefix}.description`, description); + } this.setValue(`${cmiPrefix}.timestamp`, this.getISO8601Timestamp()); } - recordInteractionMultipleChoice(id, response, correct, latency, type) { + recordInteractionMultipleChoice(id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description) { if (this.isSCORM2004()) { response = response.replace(/,|#/g, '[,]'); } else { response = response.replace(/#/g, ','); response = this.checkResponse(response, 'choice'); + correctResponsesPattern = correctResponsesPattern.map(response => response.replace(/\[,\]/g, ',')); } const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - scormRecordInteraction.call(this, id, response, correct, latency, type); + scormRecordInteraction.call(this, id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description); } - recordInteractionMatching(id, response, correct, latency, type) { + recordInteractionMatching(id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description) { response = response.replace(/#/g, ','); if (this.isSCORM2004()) { response = response.replace(/,/g, '[,]').replace(/\./g, '[.]'); } else { response = this.checkResponse(response, 'matching'); + // @todo: source prefix on target is not allowed in SCORM 1.2 - see https://github.com/adaptlearning/adapt-contrib-matching/issues/199 + correctResponsesPattern = correctResponsesPattern.map(response => response.replace(/\[\.\]/g, '.').replace(/\[,\]/g, ',')); } const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - scormRecordInteraction.call(this, id, response, correct, latency, type); + scormRecordInteraction.call(this, id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description); } - recordInteractionFillIn(id, response, correct, latency, type) { + recordInteractionFillIn(id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description) { let maxLength = this.isSCORM2004() ? 250 : 255; maxLength = this.maxCharLimitOverride ?? maxLength; if (response.length > maxLength) { @@ -679,7 +707,7 @@ class ScormWrapper { 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.`); } const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; - scormRecordInteraction.call(this, id, response, correct, latency, type); + scormRecordInteraction.call(this, id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description); } getObjectiveCount() { From d041c701d2e24174bd05d2cd65f56d1a66cf7d55 Mon Sep 17 00:00:00 2001 From: danielghost Date: Wed, 4 Dec 2024 17:19:55 +0000 Subject: [PATCH 2/5] Excluded SCORM 1.2 from the additional `cmi.interactions` context as it's implementation isn't as well defined or supported by many LMSs (see https://github.com/adaptlearning/adapt-contrib-spoor/issues/281#issuecomment-2508483223). --- js/scorm/wrapper.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index e66565e..0d154d7 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -627,7 +627,7 @@ class ScormWrapper { return count === '' ? 0 : count; } - recordInteractionScorm12(id, response, correct, latency, type, correctResponsesPattern, objectiveIds) { + recordInteractionScorm12(id, response, correct, latency, type) { id = id.trim(); const cmiPrefix = `cmi.interactions.${this.getInteractionCount()}`; this.setValue(`${cmiPrefix}.id`, id); @@ -635,16 +635,6 @@ class ScormWrapper { this.setValueIfChildSupported(`${cmiPrefix}.student_response`, response); this.setValueIfChildSupported(`${cmiPrefix}.result`, correct ? 'correct' : 'wrong'); if (latency !== null && latency !== undefined) this.setValueIfChildSupported(`${cmiPrefix}.latency`, this.convertToSCORM12Time(latency)); - if (this.isChildSupported(`${cmiPrefix}.correct_responses`) && correctResponsesPattern?.length) { - correctResponsesPattern.forEach((response, index) => { - this.setValue(`${cmiPrefix}.correct_responses.${index}.pattern`, response); - }); - } - if (this.isChildSupported(`${cmiPrefix}.objectives`) && objectiveIds?.length) { - objectiveIds.forEach((id, index) => { - this.setValue(`${cmiPrefix}.objectives.${index}.id`, id); - }); - } this.setValueIfChildSupported(`${cmiPrefix}.time`, this.getCMITime()); } @@ -680,7 +670,6 @@ class ScormWrapper { } else { response = response.replace(/#/g, ','); response = this.checkResponse(response, 'choice'); - correctResponsesPattern = correctResponsesPattern.map(response => response.replace(/\[,\]/g, ',')); } const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; scormRecordInteraction.call(this, id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description); @@ -692,8 +681,6 @@ class ScormWrapper { response = response.replace(/,/g, '[,]').replace(/\./g, '[.]'); } else { response = this.checkResponse(response, 'matching'); - // @todo: source prefix on target is not allowed in SCORM 1.2 - see https://github.com/adaptlearning/adapt-contrib-matching/issues/199 - correctResponsesPattern = correctResponsesPattern.map(response => response.replace(/\[\.\]/g, '.').replace(/\[,\]/g, ',')); } const scormRecordInteraction = this.isSCORM2004() ? this.recordInteractionScorm2004 : this.recordInteractionScorm12; scormRecordInteraction.call(this, id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description); From 807ff963efeb13fa19f7d6960fd9369db749b704 Mon Sep 17 00:00:00 2001 From: danielghost Date: Wed, 4 Dec 2024 18:07:20 +0000 Subject: [PATCH 3/5] Strip HTML from `cmi.interactions.n.description`. --- js/scorm/wrapper.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index 0d154d7..3d51b9d 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -658,6 +658,8 @@ class ScormWrapper { } if (description) { const maxLength = 250; + // strip HTML + description = $(`

${description}

`).text(); if (description.length > maxLength) description = description.substr(0, maxLength).trim(); this.setValue(`${cmiPrefix}.description`, description); } From 8ca355657f5834a8e6cc8b45c1f5bde6657e84c6 Mon Sep 17 00:00:00 2001 From: danielghost Date: Thu, 5 Dec 2024 10:13:22 +0000 Subject: [PATCH 4/5] Added missing `objectiveIds` param to `recordInteraction`. Isn't actually needed as `...arguments` is passed to other methods, but added for clarity and consistency. --- js/scorm/wrapper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index 3d51b9d..5e15ff1 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -362,7 +362,7 @@ class ScormWrapper { })); } - recordInteraction(id, response, correct, latency, type, correctResponsesPattern, description) { + recordInteraction(id, response, correct, latency, type, correctResponsesPattern, objectiveIds, description) { if (!this.isChildSupported('cmi.interactions.n.id') || !this.isSupported('cmi.interactions._count')) return; switch (type) { case 'choice': From 1492e62ad224faa721b020007204037148ec60bd Mon Sep 17 00:00:00 2001 From: danielghost Date: Mon, 13 Jan 2025 12:32:19 +0000 Subject: [PATCH 5/5] Account for `maxCharLimitOverride`. --- js/scorm/wrapper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index 5e15ff1..7e64ab3 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -657,7 +657,7 @@ class ScormWrapper { }); } if (description) { - const maxLength = 250; + const maxLength = this.maxCharLimitOverride ?? 250; // strip HTML description = $(`

${description}

`).text(); if (description.length > maxLength) description = description.substr(0, maxLength).trim();