diff --git a/README.md b/README.md index c8f6940d..8e3eb01b 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ Determines whether the `cmi.completion_status` is set to "completed" if the asse #### \_showCookieLmsResetButton (boolean): Determines whether a reset button will be available to relaunch the course and optionally clear tracking data (scorm_test_harness.html only). The default is `false`. +#### \_shouldPersistCookieLMSData (boolean): +Determines whether to persist the cookie data over browser sessions (scorm_test_harness.html only). The default is `true`. +
## Notes diff --git a/example.json b/example.json index 45898f3d..cf0331ee 100644 --- a/example.json +++ b/example.json @@ -29,7 +29,8 @@ "_exitStateIfComplete": "auto", "_setCompletedWhenFailed": true }, - "_showCookieLmsResetButton": false + "_showCookieLmsResetButton": false, + "_shouldPersistCookieLMSData": true } // to be added to course/en/course.json (note: you only need to add the ones you want to change/translate) diff --git a/js/adapt-contrib-spoor.js b/js/adapt-contrib-spoor.js index 7bc8615f..a48c0690 100644 --- a/js/adapt-contrib-spoor.js +++ b/js/adapt-contrib-spoor.js @@ -3,11 +3,13 @@ import ScormWrapper from './scorm/wrapper'; import StatefulSession from './adapt-stateful-session'; import OfflineStorage from './adapt-offlineStorage-scorm'; import offlineStorage from 'core/js/offlineStorage'; +import { shouldStart as shouldStartCookieLMS, start as startCookieLMS } from './scorm/cookieLMS'; class Spoor extends Backbone.Controller { initialize() { this.config = null; + if (shouldStartCookieLMS) startCookieLMS(); this.scorm = ScormWrapper.getInstance(); this.listenToOnce(Adapt, 'offlineStorage:prepare', this._prepare); } diff --git a/js/scorm/cookieLMS.js b/js/scorm/cookieLMS.js new file mode 100644 index 00000000..087df92a --- /dev/null +++ b/js/scorm/cookieLMS.js @@ -0,0 +1,189 @@ +import Adapt from 'core/js/adapt'; +import Cookies from 'libraries/js-cookie.js'; + +/** Start the mock API if window.ISCOOKIELMS exists and isn't null */ +export const shouldStart = (Object.prototype.hasOwnProperty.call(window, 'ISCOOKIELMS') && window.ISCOOKIELMS !== null); + +/** Store the data in a cookie if window.ISCOOKIELMS is true, otherwise setup the API without storing data. */ +export const isStoringData = (window.ISCOOKIELMS === true); + +export function createResetButton() { + const resetButtonStyle = ''; + const resetButton = ''; + $('body').append($(resetButtonStyle)); + const $button = $(resetButton); + $('body').append($button); + $button.on('click', e => { + if (!e.shiftKey) { + Cookies.remove('_spoor'); + alert('SCORM tracking cookie has been deleted! Tip: shift-click reset to preserve cookie.'); + } + window.location = window.location.pathname; + }); +} + +export function configure() { + if (!isStoringData) return; + const spoorConfig = Adapt.config.get('_spoor'); + if (spoorConfig?._showCookieLmsResetButton) createResetButton(); + if (!spoorConfig?._shouldPersistCookieLMSData) { + Cookies.defaults = { + // uncomment this if you need the cookie to 'persist'. if left commented-out it will act as a 'session' cookie + // see https://github.com/js-cookie/js-cookie/tree/latest#expires + /* expires: 365, */ + sameSite: 'strict' + }; + } +} + +export function postStorageWarning() { + if (postStorageWarning.__storageWarningTimeoutId !== null) return; + postStorageWarning.__storageWarningTimeoutId = setTimeout(() => { + const notificationMethod = (Adapt.config.get('_spoor')?._advancedSettings?._suppressErrors === true) + ? console.error + : alert; + postStorageWarning.__storageWarningTimeoutId = null; + notificationMethod('Warning: possible cookie storage limit exceeded - tracking may malfunction'); + }, 1000); +} + +export function start () { + + const GenericAPI = { + + __offlineAPIWrapper: true, + + store: function(force) { + if (!isStoringData) return; + + if (!force && Cookies.get('_spoor') === undefined) return; + + Cookies.set('_spoor', this.data); + + // a length mismatch will most likely indicate cookie storage limit exceeded + if (Cookies.get('_spoor').length !== JSON.stringify(this.data).length) postStorageWarning(); + }, + + fetch: function() { + if (!isStoringData) { + this.data = {}; + return; + } + + this.data = Cookies.getJSON('_spoor'); + + if (!this.data) { + this.data = {}; + return false; + } + + return true; + } + + }; + + // SCORM 1.2 API + window.API = { + + ...GenericAPI, + + LMSInitialize: function() { + 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); + } + return 'true'; + }, + + LMSFinish: function() { + return 'true'; + }, + + LMSGetValue: function(key) { + return this.data[key]; + }, + + LMSSetValue: function(key, value) { + const str = 'cmi.interactions.'; + if (key.indexOf(str) !== -1) return 'true'; + + this.data[key] = value; + + this.store(); + return 'true'; + }, + + LMSCommit: function() { + return 'true'; + }, + + LMSGetLastError: function() { + return 0; + }, + + LMSGetErrorString: function() { + return 'Fake error string.'; + }, + + LMSGetDiagnostic: function() { + return 'Fake diagnostic information.'; + } + }; + + // SCORM 2004 API + window.API_1484_11 = { + + ...GenericAPI, + + Initialize: function() { + 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); + } + 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.'; + } + + }; +} diff --git a/properties.schema b/properties.schema index 162b2904..42e7e380 100644 --- a/properties.schema +++ b/properties.schema @@ -269,6 +269,15 @@ "inputType": "Checkbox", "validators": [], "help": "If enabled, a reset button will be available to relaunch the course and optionally clear tracking data (scorm_test_harness.html only)." + }, + "_shouldPersistCookieLMSData": { + "type": "boolean", + "required": false, + "default": true, + "title": "Persist cookie data (scorm_test_harness.html only)", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, the course data will persist over browser sessions (scorm_test_harness.html only)." } } } diff --git a/required/index.html b/required/index.html index 3cc83604..b51ae782 100644 --- a/required/index.html +++ b/required/index.html @@ -5,10 +5,9 @@ window.ADAPT_BUILD_TYPE = '@@build.type'; window.ISCOOKIELMS = false; - -