From f88877c073f25afe16e924ad12b8c32fc81c49ef Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 14 Jun 2023 10:56:04 +0100 Subject: [PATCH] New: Added _uniqueInteractionIds, testing support for cmi.interactions (#278) --- README.md | 3 + example.json | 3 +- js/adapt-stateful-session.js | 6 +- js/scorm/cookieLMS.js | 160 +++++++++++++++++++++-------------- properties.schema | 9 ++ schema/config.schema.json | 6 ++ 6 files changed, 120 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index b5fa2f75..128127cf 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ Determines whether a reset button will be available to relaunch the course and o #### \_shouldPersistCookieLMSData (boolean): Determines whether to persist the cookie data over browser sessions (scorm_test_harness.html only). The default is `true`. +#### \_uniqueInteractionIds (boolean): +Determines whether `cmi.interactions.n.id` will be prepended with an index, making the id unique. Some LMSes require unique ids, this will inhibit the grouping of interactions by id on the server-side. +
Back to Top
## Notes diff --git a/example.json b/example.json index 3d177cbc..84e347cb 100644 --- a/example.json +++ b/example.json @@ -33,7 +33,8 @@ "_testOnSetValue": true, "_silentRetryLimit": 0, "_silentRetryDelay": 2000 - } + }, + "_uniqueInteractionIds": false }, "_showCookieLmsResetButton": false, "_shouldPersistCookieLMSData": true diff --git a/js/adapt-stateful-session.js b/js/adapt-stateful-session.js index 9e7d1328..38d1b492 100644 --- a/js/adapt-stateful-session.js +++ b/js/adapt-stateful-session.js @@ -19,6 +19,7 @@ export default class StatefulSession extends Backbone.Controller { this._shouldStoreResponses = true; this._shouldStoreAttempts = false; this._shouldRecordInteractions = true; + this._uniqueInteractionIds = false; this.beginSession(); } @@ -51,6 +52,7 @@ export default class StatefulSession extends Backbone.Controller { this.scorm.initialize(); return; } + this._uniqueInteractionIds = settings._uniqueInteractionIds || false; this.scorm.initialize(settings); } @@ -177,7 +179,9 @@ export default class StatefulSession extends Backbone.Controller { // 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 = `${this.scorm.getInteractionCount()}-${questionModel.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()); diff --git a/js/scorm/cookieLMS.js b/js/scorm/cookieLMS.js index 087df92a..5bb3facc 100644 --- a/js/scorm/cookieLMS.js +++ b/js/scorm/cookieLMS.js @@ -7,6 +7,33 @@ export const shouldStart = (Object.prototype.hasOwnProperty.call(window, 'ISCOOK /** Store the data in a cookie if window.ISCOOKIELMS is true, otherwise setup the API without storing data. */ export const isStoringData = (window.ISCOOKIELMS === true); +/** + * Store value nested inside object at given path + * @param {Object} object Root of hierarchy + * @param {string} path Period separated key names + * @param {*} value Value to store at final path + */ +export const set = (object, path, value) => { + const keys = path.split('.'); + const initialKeys = keys.slice(0, -1); + const lastKey = keys[keys.length - 1]; + const finalObject = initialKeys.reduce((object, key) => { + return (object[key] = object?.[key] || {}); + }, object); + finalObject[lastKey] = value; +}; + +/** + * Fetch value nested inside object at given path + * @param {Object} object + * @param {string} path Period separated key names + * @returns + */ +export const get = (object, path) => { + const keys = path.split('.'); + return keys.reduce((object, key) => object?.[key], object); +}; + export function createResetButton() { const resetButtonStyle = ''; const resetButton = ''; @@ -53,7 +80,7 @@ export function start () { __offlineAPIWrapper: true, - store: function(force) { + store(force) { if (!isStoringData) return; if (!force && Cookies.get('_spoor') === undefined) return; @@ -64,9 +91,10 @@ export function start () { if (Cookies.get('_spoor').length !== JSON.stringify(this.data).length) postStorageWarning(); }, - fetch: function() { + initialize(defaults = {}) { if (!isStoringData) { this.data = {}; + Object.entries(defaults).forEach(([path, value]) => set(this.data, path, value)); return; } @@ -74,62 +102,90 @@ export function start () { if (!this.data) { this.data = {}; + Object.entries(defaults).forEach(([path, value]) => set(this.data, path, value)); + this.store(true); return false; } + const entries = Object.entries(this.data); + const isUsingLegacyKeys = (entries[0][0].includes('.')); + if (isUsingLegacyKeys) { + /** + * convert from: cmi.student_name = '' + * to: { cmi: { student_name: '' } } + */ + const reworked = {}; + Object.entries(defaults).forEach(([path, value]) => set(reworked, path, value)); + Object.entries(entries).forEach(([path, value]) => set(reworked, path, value)); + this.data = reworked; + this.store(true); + } + return true; } }; // SCORM 1.2 API - window.API = { + const SCORM1_2 = window.API = { ...GenericAPI, - LMSInitialize: function() { + LMSInitialize() { configure(); - if (!this.fetch()) { - this.data['cmi.core.lesson_status'] = 'not attempted'; - this.data['cmi.suspend_data'] = ''; - this.data['cmi.core.student_name'] = 'Surname, Sam'; - this.data['cmi.core.student_id'] = 'sam.surname@example.org'; - this.store(true); - } + this.initialize({ + 'cmi.interactions': [], + 'cmi.core.lesson_status': 'not attempted', + 'cmi.suspend_data': '', + 'cmi.core.student_name': 'Surname, Sam', + 'cmi.core.student_id': 'sam.surname@example.org' + }); return 'true'; }, - LMSFinish: function() { + LMSFinish() { return 'true'; }, - LMSGetValue: function(key) { - return this.data[key]; + LMSGetValue(path) { + const value = get(this.data, path); + const keys = path.split('.'); + const firstKey = keys[0]; + const lastKey = keys[keys.length - 1]; + if (firstKey === 'cmi' && lastKey === '_count') { + // Treat requests for cmi.*._count as an array length query + const arrayPath = keys.slice(0, -1).join('.'); + return get(this.data, arrayPath)?.length ?? 0; + } + return value; }, - LMSSetValue: function(key, value) { - const str = 'cmi.interactions.'; - if (key.indexOf(str) !== -1) return 'true'; - - this.data[key] = value; - + LMSSetValue(path, value) { + const keys = path.split('.'); + const firstKey = keys[0]; + const lastKey = keys[keys.length - 1]; + if (firstKey === 'cmi' && lastKey === '_count') { + // Fail silently + return 'true'; + } + set(this.data, path, value); this.store(); return 'true'; }, - LMSCommit: function() { + LMSCommit() { return 'true'; }, - LMSGetLastError: function() { + LMSGetLastError() { return 0; }, - LMSGetErrorString: function() { + LMSGetErrorString() { return 'Fake error string.'; }, - LMSGetDiagnostic: function() { + LMSGetDiagnostic() { return 'Fake diagnostic information.'; } }; @@ -139,51 +195,25 @@ export function start () { ...GenericAPI, - Initialize: function() { + Initialize() { configure(); - if (!this.fetch()) { - this.data['cmi.completion_status'] = 'not attempted'; - this.data['cmi.suspend_data'] = ''; - this.data['cmi.learner_name'] = 'Surname, Sam'; - this.data['cmi.learner_id'] = 'sam.surname@example.org'; - this.store(true); - } + this.initialize({ + 'cmi.interactions': [], + 'cmi.completion_status': 'not attempted', + 'cmi.suspend_data': '', + 'cmi.learner_name': 'Surname, Sam', + 'cmi.learner_id': 'sam.surname@example.org' + }); return 'true'; }, - Terminate: function() { - return 'true'; - }, - - GetValue: function(key) { - return this.data[key]; - }, - - SetValue: function(key, value) { - const str = 'cmi.interactions.'; - if (key.indexOf(str) !== -1) return 'true'; - - this.data[key] = value; - - this.store(); - return 'true'; - }, - - Commit: function() { - return 'true'; - }, - - GetLastError: function() { - return 0; - }, - - GetErrorString: function() { - return 'Fake error string.'; - }, - - GetDiagnostic: function() { - return 'Fake diagnostic information.'; - } + Terminate: SCORM1_2.LMSFinish, + GetValue: SCORM1_2.LMSGetValue, + SetValue: SCORM1_2.LMSSetValue, + Commit: SCORM1_2.LMSCommit, + GetLastError: SCORM1_2.LMSGetLastError, + GetErrorString: SCORM1_2.LMSGetErrorString, + GetDiagnostic: SCORM1_2.LMSGetDiagnostic }; } diff --git a/properties.schema b/properties.schema index 53f321fa..6af08562 100644 --- a/properties.schema +++ b/properties.schema @@ -299,6 +299,15 @@ "help": "The interval in milliseconds between silent connection retries." } } + }, + "_uniqueInteractionIds": { + "type": "boolean", + "required": false, + "default": false, + "title": "Unique Interaction Ids", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, `cmi.interactions.n.id` will be prepended with an index, making the id unique. Some LMSes require unique ids, this will inhibit the grouping of interactions by id on the server-side." } } }, diff --git a/schema/config.schema.json b/schema/config.schema.json index 33567d3c..31b05043 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -215,6 +215,12 @@ "default": 1000 } } + }, + "_uniqueInteractionIds": { + "type": "boolean", + "title": "Unique Interaction Ids", + "description": "If enabled, `cmi.interactions.n.id` will be prepended with an index, making the id unique. Some LMSes require unique ids, this will inhibit the grouping of interactions by id on the server-side.", + "default": false } } },