diff --git a/README.md b/README.md
index f0442ae..153e968 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,12 @@ Please refer to [example.json](example.json) for the JSON snippet which should b
| Property | Type | Default | Description |
|--|--|--|--|
-| \_isEnabled | Boolean | `false` | Set to `true` to enable the extension.
+| \_isEnabled | Boolean | `true` | Set to `true` to enable the extension.
+| \_isRestoreEnabled | Boolean | `false` | If enabled, the course will use xAPI data to restore course progress.
+| \_isDebugModeEnabled | Boolean | `false` | If enabled, the course will continue to generate statements for testing but it will instead post the statement to the console instead of the LRS.
| \_activityId | String | `""` | Used to populate the Activity id included in _tincan.xml_ and for use in the Statement and State API. The value will be overriden if changed on the hosting environment.
| \_revision | String | `""` | This helps identify users running particular versions of the content, should functionality have been changed or issues found which require updates/voided statements for users running a specific revision. Where data has changed significantly, a new Activity id is recommended.
-| \_tracking | Object | `{ _storeQuestionResponses: true, _questionInteractions: true, _assessmentsCompletion: false, _assessmentCompletion: true }` |
- \_storeQuestionResponses: Restore question responses across browser sessions.
- \_questionInteractions: Record statements for questions.
- \_assessmentsCompletion: Record a completed statement on completion of individual assessments.
- \_assessmentCompletion: Record a completed statement on completion of all assessments combined.
+| \_tracking | Object | `{ _storeQuestionResponses: true, _questionInteractions: true, _assessmentsCompletion: false, _assessmentCompletion: true, _navbar: true, _trackingErrors: true, _visua11y: false }` | - \_storeQuestionResponses: Restore question responses across browser sessions.
- \_questionInteractions: Record statements for questions.
- \_assessmentsCompletion: Record a completed statement on completion of individual assessments.
- \_assessmentCompletion: Record a completed statement on completion of all assessments combined.
- \_navbar: If enabled, the course will send a statement whenever a selection has been made with `pageLevelProgress`, `drawer`, `help` or `visua11y`.
- \_trackingErrors: If enabled, the course will send a statement whenever a connection issue occurs with the spoor plug-in.
- \_visua11y: If enabled, the course will send a statement whenever a preference has been selected within the visua11y options.
| \_errors | Object | See [example.json](example.json) | The `title` and `body` content to be displayed to the user in the event that data cannot be sent to the LRS. `_isCancellable: false` can be used to prevent the user from closing the error and proceeding with the course (requires fix for https://github.com/adaptlearning/adapt_framework/issues/2743).
The types of errors are as follows:- \_launch: For errors associated with a failed launch when connecting to the LRS.
- \_lrs: For errors associated with failed communication to the LRS when using the Statement or State API.
- \_activityId: Error to indicate the Activity id is missing and data will not be tracked.
## Launch types
@@ -31,21 +33,26 @@ The granular detail of what is included within each statement and how to analyse
|--|--|
| http://adlnet.gov/expapi/activities/course | Defines the course model.
| http://adlnet.gov/expapi/verbs/module | Defines a content page model.
-| http://adlnet.gov/expapi/activities/interaction | Defines a component model.
+| http://adlnet.gov/expapi/activities/interaction | Defines a component model or UI element.
| http://adlnet.gov/expapi/verbs/cmi.interaction | Defines a question component model.
| http://adlnet.gov/expapi/activities/assessment | Defines an assessment article model and/or the overall assessment.
+| http://id.tincanapi.com/activitytype/resource | Defines a resource link.
+| http://id.tincanapi.com/activitytype/vocabulary-word | Defines a glossary term.
### Verbs
| Verb | id | Description |
|--|--|--|
| initialized | http://adlnet.gov/expapi/verbs/initialized | Sent once the course has been launched, to indicate the start of the session.
| terminated | http://adlnet.gov/expapi/verbs/terminated | Sent once the course has been closed, to indicate the end of a session.
-| preferred | http://adlnet.gov/expapi/verbs/preferred | Sent to indicate a user’s language preference.
+| preferred | http://adlnet.gov/expapi/verbs/preferred | Sent to indicate a user’s language preference or chosen course preference in visua11y.
| completed | http://adlnet.gov/expapi/verbs/completed | Sent once an activity within the course has been completed. As default, restricted to course and page models. Component completion can be enabled as required for each model in _components.json_ via `_recordCompletion: true`.
| experienced | http://adlnet.gov/expapi/verbs/experienced | Sent each time a user leaves a page.
| answered | http://adlnet.gov/expapi/verbs/answered | Sent each time a question component has been answered.
| passed | http://adlnet.gov/expapi/verbs/passed | Sent should a user achieve the required passmark for an assessment.
| failed | http://adlnet.gov/expapi/verbs/failed | Sent should a user score below the required passmark for an assessment.
+| received | http://activitystrea.ms/schema/1.0/receive | Sent one a learner is served a notification, such as a loss of connectivity with an LMS.
+| accessed | http://activitystrea.ms/schema/1.0/access | Sent one a learner selected a link to a resource.
+| viewed | http://id.tincanapi.com/verb/viewed | Sent one a learner opened a glossary term within the drawer.
## Limitations
- Does not support multiple endpoints for storing data to more than one LRS.
diff --git a/example.json b/example.json
index edfe618..09cf864 100644
--- a/example.json
+++ b/example.json
@@ -2,6 +2,8 @@
{
"_xapi": {
"_isEnabled": true,
+ "_isRestoreEnabled": true,
+ "_isDebugModeEnabled": false,
"_comment": "included in tincan.xml. _activityId will be overriden if changed on the hosting environment.",
"_activityId": "https://example.com/unique_identifier",
"_revision": "",
@@ -9,7 +11,10 @@
"_storeQuestionResponses": true,
"_questionInteractions": true,
"_assessmentsCompletion": false,
- "_assessmentCompletion": true
+ "_assessmentCompletion": true,
+ "_navbar": true,
+ "_trackingErrors": true,
+ "_visua11y": false
},
"_errors": {
"_launch": {
@@ -32,4 +37,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/js/adapt-kineo-xapi.js b/js/adapt-kineo-xapi.js
index 6eed94a..8f03154 100644
--- a/js/adapt-kineo-xapi.js
+++ b/js/adapt-kineo-xapi.js
@@ -1,173 +1,180 @@
-define([
- 'core/js/adapt',
- './offlineStorage',
- './errorNotificationModel',
- './launchModel',
- './statementModel',
- './stateModel',
- 'libraries/xapiwrapper.min',
- 'libraries/url-polyfill',
- 'libraries/fetch-polyfill',
- 'libraries/promise-polyfill.min'
-], function(Adapt, OfflineStorage, ErrorNotificationModel, LaunchModel, StatementModel, StateModel) {
-
- const xAPI = Backbone.Controller.extend({
-
- _isInitialized: false,
- _config: null,
- _activityId: null,
- _restoredLanguage: null,
- _currentLanguage: null,
- errorNotificationModel: null,
- launchModel: null,
- statementModel: null,
- stateModel: null,
-
- initialize: function() {
- this.listenToOnce(Adapt, 'offlineStorage:prepare', this.onPrepareOfflineStorage);
- },
-
- initializeErrorNotification: function() {
- const config = this._config._errors;
-
- this.errorNotificationModel = new ErrorNotificationModel(config);
- },
-
- initializeLaunch: function() {
- this.listenToOnce(Adapt, {
- 'xapi:launchInitialized': this.onLaunchInitialized,
- 'xapi:launchFailed': this.onLaunchFailed
- });
-
- this.launchModel = new LaunchModel();
- },
-
- initializeState: function() {
- this.listenTo(Adapt, 'xapi:stateLoaded', this.onStateLoaded);
-
- const config = {
- activityId: this.getActivityId(),
- registration: this.launchModel.get('registration'),
- actor: this.launchModel.get('actor')
- };
-
- this.stateModel = new StateModel(config, {
- wrapper: this.launchModel.getWrapper(),
- _tracking: this._config._tracking
- });
- },
-
- initializeStatement: function() {
- const config = {
- activityId: this.getActivityId(),
- registration: this.launchModel.get('registration'),
- revision: this._config._revision || null,
- actor: this.launchModel.get('actor'),
- contextActivities: this.launchModel.get('contextActivities')
- };
-
- this.statementModel = new StatementModel(config, {
- wrapper: this.launchModel.getWrapper(),
- _tracking: this._config._tracking
- });
- },
-
- getActivityId: function() {
- if (this._activityId) return this._activityId;
-
- const lrs = this.launchModel.getWrapper().lrs;
- // if using cmi5 the activityId MUST come from the query string for "cmi.defined" statements
- let activityId = lrs.activityId || lrs.activity_id || this._config._activityId;
-
- // @todo: should activityId be derived from URL? Would suggest not as the domain may not be controlled by the author/vendor
- if (!activityId) Adapt.trigger('xapi:activityIdError');
-
- // remove trailing slash if included
- activityId = activityId.replace(/\/?$/, '');
-
- return activityId;
- },
-
- // @todo: offlineStorage conflict with adapt-contrib-spoor
- onPrepareOfflineStorage: function() {
- this._config = Adapt.config.get('_xapi');
-
- if (this._config && this._config._isEnabled) {
- Adapt.wait.begin();
-
- Adapt.offlineStorage.initialize(OfflineStorage);
-
- this.initializeErrorNotification();
- this.initializeLaunch();
- }
- },
-
- onLaunchInitialized: function() {
- this._activityId = this.getActivityId();
-
- if (!this._activityId) {
- this.onLaunchFailed();
-
- return;
- }
-
- this.listenToOnce(Adapt, {
- 'offlineStorage:ready': this.onOfflineStorageReady,
- 'app:dataLoaded': this.onDataLoaded,
- 'adapt:initialize': this.onAdaptInitialize
- });
-
- this.listenTo(Adapt, {
- 'app:languageChanged': this.onLanguageChanged
- });
+import Adapt from 'core/js/adapt';
+import wait from 'core/js/wait';
+import offlineStorage from 'core/js/offlineStorage';
+import OfflineStorageExtension from './offlineStorageExtension';
+import ErrorNotificationModel from './errorNotificationModel';
+import LaunchModel from './launchModel';
+import StatementModel from './statementModel';
+import StateModel from './stateModel';
+import 'libraries/xapiwrapper.min';
+import 'libraries/url-polyfill';
+import 'libraries/fetch-polyfill';
+import 'libraries/promise-polyfill.min';
+
+class xAPI extends Backbone.Controller {
+
+ defaults() {
+ return {
+ _isInitialized: false,
+ _config: null,
+ _activityId: null,
+ _restoredLanguage: null,
+ _currentLanguage: null,
+ errorNotificationModel: null,
+ launchModel: null,
+ statementModel: null,
+ stateModel: null,
+ _navbar: true,
+ _visua11y: true,
+ _trackingErrors: true
+ };
+ }
+
+ initialize() {
+ this.listenToOnce(Adapt, 'offlineStorage:prepare', this.onPrepareOfflineStorage);
+ }
+
+ initializeErrorNotification() {
+ const config = this._config._errors;
+
+ this.errorNotificationModel = new ErrorNotificationModel(config);
+ }
+
+ initializeLaunch() {
+ this.listenToOnce(Adapt, {
+ 'xapi:launchInitialized': this.onLaunchInitialized,
+ 'xapi:launchFailed': this.onLaunchFailed
+ });
+
+ this.launchModel = new LaunchModel();
+ }
+
+ initializeState() {
+ this.listenTo(Adapt, 'xapi:stateLoaded', this.onStateLoaded);
+
+ const config = {
+ activityId: this.getActivityId(),
+ registration: this.launchModel.get('registration'),
+ actor: this.launchModel.get('actor')
+ };
+
+ this.stateModel = new StateModel(config, {
+ wrapper: this.launchModel.getWrapper(),
+ _tracking: this._config._tracking
+ });
+ }
+
+ initializeStatement() {
+ const config = {
+ activityId: this.getActivityId(),
+ registration: this.launchModel.get('registration'),
+ revision: this._config._revision || null,
+ actor: this.launchModel.get('actor'),
+ contextActivities: this.launchModel.get('contextActivities')
+ };
+
+ this.statementModel = new StatementModel(config, {
+ wrapper: this.launchModel.getWrapper(),
+ _tracking: this._config._tracking
+ });
+ }
+
+ getActivityId() {
+ if (this._activityId) return this._activityId;
+
+ const lrs = this.launchModel.getWrapper().lrs;
+ // if using cmi5 the activityId MUST come from the query string for "cmi.defined" statements
+ let activityId = lrs.activityId || lrs.activity_id || this._config._activityId;
+
+ // @todo: should activityId be derived from URL? Would suggest not as the domain may not be controlled by the author/vendor
+ if (!activityId) Adapt.trigger('xapi:activityIdError');
+
+ // remove trailing slash if included
+ activityId = activityId.replace(/\/?$/, '');
+
+ return activityId;
+ }
+
+ // @todo: offlineStorage conflict with adapt-contrib-spoor
+ onPrepareOfflineStorage() {
+ this._config = Adapt.config.get('_xapi');
+
+ if (this._config && this._config._isEnabled) {
+ wait.begin();
+
+ offlineStorage.initialize(OfflineStorageExtension);
+
+ this.initializeErrorNotification();
+ this.initializeLaunch();
+ }
+ }
- this.initializeState();
- this.initializeStatement();
- },
+ onLaunchInitialized() {
+ this._activityId = this.getActivityId();
- onLaunchFailed: function() {
- Adapt.wait.end();
+ if (!this._activityId) {
+ this.onLaunchFailed();
- Adapt.offlineStorage.setReadyStatus();
- },
+ return;
+ }
- onOfflineStorageReady: function() {
- this._restoredLanguage = Adapt.offlineStorage.get('lang');
- },
+ this.listenToOnce(Adapt, {
+ 'offlineStorage:ready': this.onOfflineStorageReady,
+ 'app:dataLoaded': this.onDataLoaded,
+ 'adapt:initialize': this.onAdaptInitialize
+ });
- onLanguageChanged: function(lang) {
- const languageConfig = Adapt.config.get('_languagePicker');
+ this.listenTo(Adapt, {
+ 'app:languageChanged': this.onLanguageChanged
+ });
- if (languageConfig && languageConfig._isEnabled && this._restoredLanguage !== lang && this._currentLanguage !== lang) {
- // only reset if language has changed since the course was started - not neccessary before
- const resetState = this._isInitialized && !languageConfig._restoreStateOnLanguageChange;
+ this.initializeState();
+ this.initializeStatement();
+ }
- // @todo: only send when via a user selection? If `"_showOnCourseLoad": false`, this will still be triggered
- Adapt.trigger('xapi:languageChanged', lang, resetState);
- }
+ onLaunchFailed() {
+ wait.end();
- this._restoredLanguage = null;
- this._currentLanguage = lang;
- },
+ offlineStorage.setReadyStatus();
+ }
- onStateLoaded: function() {
- Adapt.wait.end();
+ onOfflineStorageReady() {
+ this._restoredLanguage = offlineStorage.get('lang');
+ }
- Adapt.offlineStorage.setReadyStatus();
- },
+ onLanguageChanged(lang) {
+ const languageConfig = Adapt.config.get('_languagePicker');
- onDataLoaded: function() {
- const globals = Adapt.course.get('_globals');
- if (!globals._learnerInfo) globals._learnerInfo = {};
- globals._learnerInfo = Adapt.offlineStorage.get('learnerinfo');
- },
+ if (languageConfig && languageConfig._isEnabled && this._restoredLanguage !== lang && this._currentLanguage !== lang) {
+ // only reset if language has changed since the course was started - not neccessary before
+ const resetState = this._isInitialized && !languageConfig._restoreStateOnLanguageChange;
- onAdaptInitialize: function() {
- this._isInitialized = true;
+ // @todo: only send when via a user selection? If `"_showOnCourseLoad": false`, this will still be triggered
+ Adapt.trigger('xapi:languageChanged', lang, resetState);
}
- });
+ this._restoredLanguage = null;
+ this._currentLanguage = lang;
+ }
+
+ onStateLoaded() {
+ wait.end();
+
+ offlineStorage.setReadyStatus();
+ }
+
+ onDataLoaded() {
+ const globals = Adapt.course.get('_globals');
+ if (!globals._learnerInfo) globals._learnerInfo = {};
+ globals._learnerInfo = offlineStorage.get('learnerinfo');
+ }
+
+ onAdaptInitialize() {
+ this._isInitialized = true;
+ }
+
+}
- return new xAPI();
+Adapt.xapi = new xAPI();
-});
+export default Adapt.xapi;
diff --git a/js/errorNotificationModel.js b/js/errorNotificationModel.js
index bd856c1..ae7981f 100644
--- a/js/errorNotificationModel.js
+++ b/js/errorNotificationModel.js
@@ -1,115 +1,116 @@
-define([
- 'core/js/adapt'
-], function(Adapt) {
-
- const LAUNCH_ERROR_ID = 'launch-error';
- const ACTIVITYID_ERROR_ID = 'activityId-error';
- const LRS_ERROR_ID = 'lrs-error';
-
- const ErrorNotificationModel = Backbone.Model.extend({
-
- _isReady: false,
- _isNotifyOpen: false,
- _isDeferredLoadingError: false,
- _currentNotifyId: null,
-
- initialize: function() {
- this.listenToOnce(Adapt, {
- 'app:dataLoaded': this.onDataLoaded
- });
-
- this.listenTo(Adapt, {
- 'xapi:launchError': this.onShowLaunchError,
- 'xapi:activityIdError': this.onShowActivityIdError,
- 'xapi:lrsError': this.onShowLRSError,
- 'notify:closed': this.onNotifyClosed
- });
- },
-
- _showNotification: function(config, id) {
- if (this._isReady) {
- if (!this._isNotifyOpen) {
- Adapt.log.error(config.title);
-
- const notifyConfig = this._getNotifyConfig(config, id);
-
- Adapt.trigger('notify:popup', notifyConfig);
-
- this._isNotifyOpen = true;
- this._currentNotifyId = id;
-
- } else if (this._currentNotifyId !== id) {
- this.listenToOnce(Adapt, 'notify:closed', _.partial(this._showNotification, config, id));
- }
- } else {
- this._isDeferredLoadingError = true;
-
- this.listenToOnce(Adapt, 'app:dataLoaded', _.partial(this._showNotification, config, id));
- }
- },
-
- _getNotifyConfig: function(config, id) {
- const notifyConfig = {
- title: config.title,
- body: config.body,
- _classes: 'xAPIError ' + id + ' ' + config._classes,
- _isxAPIError: true
- };
-
- let isCancellable = true;
-
- if (config.hasOwnProperty('_isCancellable')) {
- isCancellable = config._isCancellable;
- notifyConfig._isCancellable = isCancellable;
- notifyConfig._closeOnShadowClick = !isCancellable;
+import Adapt from "core/js/adapt";
+import wait from "core/js/wait";
+
+const LAUNCH_ERROR_ID = 'launch-error';
+const ACTIVITYID_ERROR_ID = 'activityId-error';
+const LRS_ERROR_ID = 'lrs-error';
+
+class ErrorNotificationModel extends Backbone.Model {
+
+ defaults() {
+ return {
+ _isReady: false,
+ _isNotifyOpen: false,
+ _isDeferredLoadingError: false,
+ _currentNotifyId: null
+ };
+ }
+
+ initialize() {
+ this.listenToOnce(Adapt, {
+ 'app:dataLoaded': this.onDataLoaded
+ });
+
+ this.listenTo(Adapt, {
+ 'xapi:launchError': this.onShowLaunchError,
+ 'xapi:activityIdError': this.onShowActivityIdError,
+ 'xapi:lrsError': this.onShowLRSError,
+ 'notify:closed': this.onNotifyClosed
+ });
+ }
+
+ _showNotification(config, id) {
+ if (this._isReady) {
+ if (!this._isNotifyOpen) {
+ Adapt.log.error(config.title);
+
+ const notifyConfig = this._getNotifyConfig(config, id);
+
+ Adapt.trigger('notify:popup', notifyConfig);
+
+ this._isNotifyOpen = true;
+ this._currentNotifyId = id;
+
+ } else if (this._currentNotifyId !== id) {
+ this.listenToOnce(Adapt, 'notify:closed', _.partial(this._showNotification, config, id));
}
+ } else {
+ this._isDeferredLoadingError = true;
- return notifyConfig;
- },
+ this.listenToOnce(Adapt, 'app:dataLoaded', _.partial(this._showNotification, config, id));
+ }
+ }
+
+ _getNotifyConfig(config, id) {
+ const notifyConfig = {
+ title: config.title,
+ body: config.body,
+ _classes: 'xAPIError ' + id + ' ' + config._classes,
+ _isxAPIError: true
+ };
+
+ let isCancellable = true;
+
+ if (config.hasOwnProperty('_isCancellable')) {
+ isCancellable = config._isCancellable;
+ notifyConfig._isCancellable = isCancellable;
+ notifyConfig._closeOnShadowClick = !isCancellable;
+ }
- /**
- * Can't show notify until data has loaded due to `import_globals` in template
- */
- onDataLoaded: function() {
- this._isReady = true;
+ return notifyConfig;
+ }
- if (this._isDeferredLoadingError) {
- Adapt.wait.begin();
+ /**
+ * Can't show notify until data has loaded due to `import_globals` in template
+ */
+ onDataLoaded() {
+ this._isReady = true;
- $('.loading').hide();
- }
- },
+ if (this._isDeferredLoadingError) {
+ wait.begin();
- onShowLaunchError: function() {
- this._showNotification(this.get('_launch'), LAUNCH_ERROR_ID);
- },
+ $('.loading').hide();
+ }
+ }
- onShowActivityIdError: function() {
- this._showNotification(this.get('_activityId'), ACTIVITYID_ERROR_ID);
- },
+ onShowLaunchError() {
+ this._showNotification(this.get('_launch'), LAUNCH_ERROR_ID);
+ }
- onShowLRSError: function() {
- this._showNotification(this.get('_lrs'), LRS_ERROR_ID);
- },
+ onShowActivityIdError() {
+ this._showNotification(this.get('_activityId'), ACTIVITYID_ERROR_ID);
+ }
- onNotifyClosed: function(notify) {
- if (!notify.model.get('_isxAPIError')) return;
+ onShowLRSError() {
+ this._showNotification(this.get('_lrs'), LRS_ERROR_ID);
+ }
- if (this._isDeferredLoadingError) {
- Adapt.wait.end();
+ onNotifyClosed(notify) {
+ if (!notify.model.get('_isxAPIError')) return;
- this._isDeferredLoadingError = false;
+ if (this._isDeferredLoadingError) {
+ wait.end();
- // cancel other errors if launch failed and user dismissed, as it won't track regardless
- this.stopListening();
- }
+ this._isDeferredLoadingError = false;
- this._isNotifyOpen = false;
- this._currentNotifyId = null;
+ // cancel other errors if launch failed and user dismissed, as it won't track regardless
+ this.stopListening();
}
- });
+ this._isNotifyOpen = false;
+ this._currentNotifyId = null;
+ }
- return ErrorNotificationModel;
+}
-});
+export default ErrorNotificationModel;
diff --git a/js/launchModel.js b/js/launchModel.js
index 1e64a89..bef0653 100644
--- a/js/launchModel.js
+++ b/js/launchModel.js
@@ -1,129 +1,125 @@
-define([
- 'core/js/adapt'
-], function(Adapt) {
+import Adapt from "core/js/adapt";
- const LaunchModel = Backbone.Model.extend({
+class LaunchModel extends Backbone.Model {
- defaults: {
+ defaults() {
+ return {
+ _xAPIWrapper: null,
+ _retryCount: 0,
+ _retryLimit: 1,
registration: null,
actor: null,
contextActivities: {
grouping: []
}
- },
+ };
+ }
- _xAPIWrapper: null,
- _retryCount: 0,
- _retryLimit: 1,
+ initialize() {
+ this.initializeLaunch();
+ }
- initialize: function() {
- this.initializeLaunch();
- },
+ initializeLaunch() {
+ const lrs = ADL.XAPIWrapper.lrs;
- initializeLaunch: function() {
- const lrs = ADL.XAPIWrapper.lrs;
+ /**
+ * can auth be sent through in a different process, e.g. OAuth?
+ * lrs.endpoint && lrs.auth have defaults in the ADL xAPIWrapper, so can't assume their existence means they are the correct credentials - errors will be handled when communicating with the LRS
+ */
+ if (lrs.endpoint && lrs.auth && lrs.actor) {
+ this._xAPIWrapper = ADL.XAPIWrapper;
- /**
- * can auth be sent through in a different process, e.g. OAuth?
- * lrs.endpoint && lrs.auth have defaults in the ADL xAPIWrapper, so can't assume their existence means they are the correct credentials - errors will be handled when communicating with the LRS
- */
- if (lrs.endpoint && lrs.auth && lrs.actor) {
- this._xAPIWrapper = ADL.XAPIWrapper;
+ // add trailing slash if missing in endpoint
+ lrs.endpoint = lrs.endpoint.replace(/\/?$/, '/');
- // add trailing slash if missing in endpoint
- lrs.endpoint = lrs.endpoint.replace(/\/?$/, '/');
+ // @todo: capture grouping URL params - unsure what data this actually contains based on specs - unlike contextActivities for ADL Launch
+ const launchData = {
+ registration: lrs.registration || null,
+ actor: JSON.parse(lrs.actor)/*,
+ 'contextActivities': launchdata.contextActivities */
+ };
- // @todo: capture grouping URL params - unsure what data this actually contains based on specs - unlike contextActivities for ADL Launch
- const launchData = {
- registration: lrs.registration || null,
- actor: JSON.parse(lrs.actor)/*,
- 'contextActivities': launchdata.contextActivities */
- };
+ this.set(launchData);
- this.set(launchData);
+ this.triggerLaunchInitialized();
+ } else {
+ ADL.launch(_.bind(this.onADLLaunchAttempt, this), false);
+ }
+ }
+
+ getWrapper() {
+ return this._xAPIWrapper;
+ }
+
+ showErrorNotification() {
+ Adapt.trigger('xapi:launchError');
+ }
+
+ triggerLaunchInitialized() {
+ _.defer(function() {
+ Adapt.trigger('xapi:launchInitialized');
+ });
+ }
+
+ onADLLaunchAttempt(err, launchdata, wrapper) {
+ /*
+ 200 = OK
+ 400 = launch already initialized
+ 404 = launch removed
+ */
+ if (!err) {
+ this._xAPIWrapper = wrapper;
+
+ // can ADL launch include registration?
+ const launchData = {
+ registration: launchdata.registration || null,
+ actor: launchdata.actor
+ };
+
+ const contextActivities = launchdata.contextActivities;
+ if (!(_.isEmpty(contextActivities))) launchData.contextActivities = contextActivities;
- this.triggerLaunchInitialized();
- } else {
- ADL.launch(_.bind(this.onADLLaunchAttempt, this), false);
- }
- },
-
- getWrapper: function() {
- return this._xAPIWrapper;
- },
-
- showErrorNotification: function() {
- Adapt.trigger('xapi:launchError');
- },
-
- triggerLaunchInitialized: function() {
- _.defer(function() {
- Adapt.trigger('xapi:launchInitialized');
- });
- },
-
- onADLLaunchAttempt: function(err, launchdata, wrapper) {
- /*
- 200 = OK
- 400 = launch already initialized
- 404 = launch removed
- */
- if (!err) {
- this._xAPIWrapper = wrapper;
-
- // can ADL launch include registration?
- const launchData = {
- registration: launchdata.registration || null,
- actor: launchdata.actor
- };
-
- const contextActivities = launchdata.contextActivities;
- if (!(_.isEmpty(contextActivities))) launchData.contextActivities = contextActivities;
-
- this.set(launchData);
-
- // store launch server details should browser be reloaded and launch server session still initialized
- sessionStorage.setItem('lrs', JSON.stringify(wrapper.lrs));
- sessionStorage.setItem('launchData', JSON.stringify(launchData));
-
- this.triggerLaunchInitialized();
- } else if (performance.navigation.type === 1) {
- this.onReload();
- } else if (this._retryCount < this._retryLimit) {
- this._retryCount++;
-
- this.initializeLaunch();
- } else {
- this.onLaunchFail();
- }
- },
+ this.set(launchData);
- // if launch session expired, will the next request to the launch server produce an error notification for the user?
- onReload: function() {
- const lrs = JSON.parse(sessionStorage.getItem('lrs'));
- const launchData = JSON.parse(sessionStorage.getItem('launchData'));
+ // store launch server details should browser be reloaded and launch server session still initialized
+ sessionStorage.setItem('lrs', JSON.stringify(wrapper.lrs));
+ sessionStorage.setItem('launchData', JSON.stringify(launchData));
- if (!lrs || !launchData) {
- this.onLaunchFail();
- return;
- }
+ this.triggerLaunchInitialized();
+ } else if (performance.navigation.type === 1) {
+ this.onReload();
+ } else if (this._retryCount < this._retryLimit) {
+ this._retryCount++;
- this._xAPIWrapper = ADL.XAPIWrapper;
- this._xAPIWrapper.changeConfig(lrs);
+ this.initializeLaunch();
+ } else {
+ this.onLaunchFail();
+ }
+ }
- this.set(launchData);
+ // if launch session expired, will the next request to the launch server produce an error notification for the user?
+ onReload() {
+ const lrs = JSON.parse(sessionStorage.getItem('lrs'));
+ const launchData = JSON.parse(sessionStorage.getItem('launchData'));
- this.triggerLaunchInitialized();
- },
+ if (!lrs || !launchData) {
+ this.onLaunchFail();
+ return;
+ }
- onLaunchFail: function() {
- Adapt.trigger('xapi:launchFailed');
+ this._xAPIWrapper = ADL.XAPIWrapper;
+ this._xAPIWrapper.changeConfig(lrs);
- this.showErrorNotification();
- }
+ this.set(launchData);
+
+ this.triggerLaunchInitialized();
+ }
- });
+ onLaunchFail() {
+ Adapt.trigger('xapi:launchFailed');
- return LaunchModel;
+ this.showErrorNotification();
+ }
+}
-});
+export default LaunchModel;
diff --git a/js/offlineStorageExtension.js b/js/offlineStorageExtension.js
new file mode 100644
index 0000000..1880250
--- /dev/null
+++ b/js/offlineStorageExtension.js
@@ -0,0 +1,49 @@
+const OfflineStorageExtension = {
+
+ // will be set to StateModel once ready - store values until then
+ model: new Backbone.Model(),
+
+ get(name) {
+ switch (name.toLowerCase()) {
+ case 'student':// for backwards-compatibility. learnerInfo is preferred and will give more information
+ return this.model.get('actor').name;
+ case 'learnerinfo':
+ return this._getActorData();
+ default:
+ return this.model.get(name);
+ }
+ },
+
+ set(name, value) {
+ this.model.set(name, value);
+ },
+
+ _getActorData: function() {
+ const actor = this.model.get('actor');
+ const id = this._getIdFromActor(actor);
+
+ /*
+ * don't think we should make any judgement on name format for firstname or lastname, as there is no standard for this in xAPI
+ * if actor.name not provided, use id IFI
+ */
+ return {
+ id,
+ name: actor.name || id
+ };
+ },
+
+ _getIdFromActor(actor) {
+ let id = actor.openid;
+ if (id) return id;
+
+ id = actor.account && actor.account.name;
+ if (id) return id;
+
+ id = actor.mbox || actor.mbox_sha1sum;
+
+ return id;
+ }
+
+}
+
+export default OfflineStorageExtension;
diff --git a/js/stateModel.js b/js/stateModel.js
index 3a3cff4..e9ff6ad 100644
--- a/js/stateModel.js
+++ b/js/stateModel.js
@@ -1,455 +1,461 @@
-define([
- 'core/js/adapt',
- './offlineStorage',
- 'libraries/async.min'
-], function(Adapt, OfflineStorage, Async) {
+import Adapt from "core/js/adapt";
+import wait from "core/js/wait";
+import OfflineStorageExtension from "./offlineStorageExtension";
+import * as Async from 'libraries/async.min';
- const COMPONENTS_KEY = 'components';
- const DURATIONS_KEY = 'durations';
+const COMPONENTS_KEY = 'components';
+const DURATIONS_KEY = 'durations';
- const StateModel = Backbone.Model.extend({
+class StateModel extends Backbone.Model {
- defaults: {
+ defaults() {
+ return {
activityId: null,
actor: null,
registration: null,
components: [],
- durations: []
- },
-
- _tracking: {
- _storeQuestionResponses: true
- },
-
- xAPIWrapper: null,
- _isInitialized: false,
- _isLoaded: false,
- _isRestored: false,
- _queues: {},
-
- initialize: function(attributes, options) {
- this.listenTo(Adapt, {
- 'adapt:initialize': this.onAdaptInitialize,
- 'xapi:languageChanged': this.onLanguageChanged,
- 'xapi:stateReset': this.onStateReset
- });
+ durations: [],
+ xAPIWrapper: null,
+ _isInitialized: false,
+ _isLoaded: false,
+ _isRestored: false,
+ _queues: {},
+ _tracking: {
+ _storeQuestionResponses: true
+ }
+ };
+ }
- this.xAPIWrapper = options.wrapper;
+ initialize(attributes, options) {
- _.extend(this._tracking, options._tracking);
+ this.listenTo(Adapt, {
+ 'adapt:initialize': this.onAdaptInitialize,
+ 'xapi:languageChanged': this.onLanguageChanged,
+ 'xapi:stateReset': this.onStateReset
+ });
- this.setOfflineStorageModel();
+ // Instance Variables
+ this._queues = this.get('_queues');
+ this.xAPIWrapper = options.wrapper;
+ this._tracking = {
+ ...this.defaults()._tracking,
+ ...options._tracking
+ };
- this.load();
- },
+ this.setOfflineStorageModel();
- setOfflineStorageModel: function() {
- const attributes = OfflineStorage.model.attributes;
+ this.load();
+ }
- for (const key in attributes) {
- this.set(key, attributes[key]);
- this.save(key);
- }
+ setOfflineStorageModel() {
+ const attributes = OfflineStorageExtension.model.attributes;
- OfflineStorage.model = this;
- },
+ for (const key in attributes) {
+ this.set(key, attributes[key]);
+ this.save(key);
+ }
- setupListeners: function() {
- this.setupModelListeners();
+ OfflineStorageExtension.model = this;
+ }
- // don't create new listeners for those which are still valid from initial course load
- if (this._isInitialized) return;
+ setupListeners() {
+ this.setupModelListeners();
- this.listenTo(Adapt, {
- 'xapi:durationsChange': this.onDurationChange,
- // ideally core would trigger `state.change` for each model so we don't have to return early for non-component types
- 'state:change': this.onTrackableStateChange
- });
- },
+ // don't create new listeners for those which are still valid from initial course load
+ if (this._isInitialized) return;
- setupModelListeners: function() {
- this.listenTo(Adapt.course, {
- 'change:_totalDuration': this.onDurationChange
- });
+ this.listenTo(Adapt, {
+ 'xapi:durationsChange': this.onDurationChange,
+ // ideally core would trigger `state.change` for each model so we don't have to return early for non-component types
+ 'state:change': this.onTrackableStateChange
+ });
+ }
- this.listenTo(Adapt.contentObjects, {
- 'change:_totalDuration': this.onDurationChange
- });
- },
+ setupModelListeners() {
+ this.listenTo(Adapt.course, {
+ 'change:_totalDuration': this.onDurationChange
+ });
- removeModelListeners: function() {
- this.stopListening(Adapt.course, {
- 'change:_totalDuration': this.onDurationChange
- });
+ this.listenTo(Adapt.contentObjects, {
+ 'change:_totalDuration': this.onDurationChange
+ });
+ }
- this.stopListening(Adapt.contentObjects, {
- 'change:_totalDuration': this.onDurationChange
- });
- },
-
- showErrorNotification: function() {
- Adapt.trigger('xapi:lrsError');
- },
-
- load: function() {
- const scope = this;
-
- this._getStates(function(err, data) {
- if (err) {
- scope.showErrorNotification();
- } else {
- const states = data;
-
- Async.each(states, function(id, callback) {
- scope._fetchState(id, function(err, data) {
- if (err) {
- scope.showErrorNotification();
- } else {
- // all data is now saved and retrieved as JSON, so no need for try/catch anymore
- scope.set(id, data);
- }
-
- callback();
- });
- }, function(err) {
+ removeModelListeners() {
+ this.stopListening(Adapt.course, {
+ 'change:_totalDuration': this.onDurationChange
+ });
+
+ this.stopListening(Adapt.contentObjects, {
+ 'change:_totalDuration': this.onDurationChange
+ });
+ }
+
+ showErrorNotification() {
+ Adapt.trigger('xapi:lrsError');
+ }
+
+ load() {
+ const scope = this;
+
+ this._getStates(function(err, data) {
+ if (err) {
+ scope.showErrorNotification();
+ } else {
+ const states = data;
+
+ Async.each(states, function(id, callback) {
+ scope._fetchState(id, function(err, data) {
if (err) {
scope.showErrorNotification();
} else {
- scope._isLoaded = true;
-
- Adapt.trigger('xapi:stateLoaded');
-
- scope.listenTo(Adapt, 'app:dataReady', scope.onDataReady);
+ // all data is now saved and retrieved as JSON, so no need for try/catch anymore
+ scope.set(id, data);
}
+
+ callback();
});
- }
- });
- },
+ }, function(err) {
+ if (err) {
+ scope.showErrorNotification();
+ } else {
+ scope._isLoaded = true;
- reset: function() {
- const scope = this;
+ Adapt.trigger('xapi:stateLoaded');
- this._getStates(function(err, data) {
- if (err) {
- scope.showErrorNotification();
- } else {
- Adapt.wait.begin();
+ scope.listenTo(Adapt, 'app:dataReady', scope.onDataReady);
+ }
+ });
+ }
+ });
+ }
- const states = data;
+ reset() {
+ const scope = this;
- Async.each(states, function(id, callback) {
- scope.delete(id, callback);
- }, function(err) {
- if (err) scope.showErrorNotification();
+ this._getStates(function(err, data) {
+ if (err) {
+ scope.showErrorNotification();
+ } else {
+ wait.begin();
- const data = {};
- data[COMPONENTS_KEY] = [];
- data[DURATIONS_KEY] = [];
- scope.set(data, { silent: true });
+ const states = data;
- Adapt.wait.end();
- });
- }
- });
- },
+ Async.each(states, function(id, callback) {
+ scope.delete(id, callback);
+ }, function(err) {
+ if (err) scope.showErrorNotification();
- restore: function() {
- this._restoreComponentsData();
- this._restoreDurationsData();
+ const data = {};
+ data[COMPONENTS_KEY] = [];
+ data[DURATIONS_KEY] = [];
+ scope.set(data, { silent: true });
- this._isRestored = true;
+ wait.end();
+ });
+ }
+ });
+ }
- Adapt.trigger('xapi:stateReady');
- },
+ restore() {
+ this._restoreComponentsData();
+ this._restoreDurationsData();
- set: function(id, value) {
- Backbone.Model.prototype.set.apply(this, arguments);
+ this._isRestored = true;
- // @todo: save every time the value changes, or only on specific events?
- if (this._isLoaded) {
- if (Adapt.terminate) {
- this.save(id);
- } else {
- const queue = this._getQueueById(id);
- queue.push(id);
- }
+ Adapt.trigger('xapi:stateReady');
+ }
+
+ set(id, value) {
+ Backbone.Model.prototype.set.apply(this, arguments);
+
+ // @todo: save every time the value changes, or only on specific events?
+ if (this._isLoaded) {
+ if (Adapt.terminate) {
+ this.save(id);
+ } else {
+ const queue = this._getQueueById(id);
+ queue.push(id);
}
- },
+ }
+ }
- save: function(id, callback) {
- const scope = this;
- const state = this.get(id);
- const data = JSON.stringify(state);
+ save(id, callback) {
+ const scope = this;
+ const state = this.get(id);
+ const data = JSON.stringify(state);
- // ensure any data being set is completed before restoring following languageChange
- if (!this._isRestored) Adapt.wait.begin();
+ // ensure any data being set is completed before restoring following languageChange
+ if (!this._isRestored) wait.begin();
- fetch(this._getStateURL(id), {
- keepalive: Adapt.terminate || false,
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: this.xAPIWrapper.lrs.auth,
- 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion
- },
- body: data
- }).then(function(response) {
- // if (response) Adapt.log.debug(response);
+ fetch(this._getStateURL(id), {
+ keepalive: Adapt.terminate || false,
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: this.xAPIWrapper.lrs.auth,
+ 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion
+ },
+ body: data
+ }).then(function(response) {
+ // if (response) Adapt.log.debug(response);
- if (!response.ok) throw Error(response.statusText);
+ if (!response.ok) throw Error(response.statusText);
- if (callback) callback();
+ if (callback) callback();
- if (!scope._isRestored) Adapt.wait.end();
+ if (!scope._isRestored) wait.end();
- return response;
- }).catch(function(error) {
- scope.showErrorNotification();
+ return response;
+ }).catch(function(error) {
+ scope.showErrorNotification();
- if (callback) callback();
+ if (callback) callback();
- if (!scope._isRestored) Adapt.wait.end();
- });
- },
+ if (!scope._isRestored) wait.end();
+ });
+ }
- delete: function(id, callback) {
- this.unset(id, { silent: true });
+ delete(id, callback) {
+ this.unset(id, { silent: true });
- const scope = this;
+ const scope = this;
- fetch(this._getStateURL(id), {
- method: 'DELETE',
- headers: {
- Authorization: this.xAPIWrapper.lrs.auth,
- 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion
- }
- }).then(function(response) {
- if (!response.ok) throw Error(response.statusText);
+ fetch(this._getStateURL(id), {
+ method: 'DELETE',
+ headers: {
+ Authorization: this.xAPIWrapper.lrs.auth,
+ 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion
+ }
+ }).then(function(response) {
+ if (!response.ok) throw Error(response.statusText);
- if (callback) callback();
+ if (callback) callback();
- return response;
- }).catch(function(error) {
- scope.showErrorNotification();
+ return response;
+ }).catch(function(error) {
+ scope.showErrorNotification();
- if (callback) callback();
- });
- },
-
- _getStateURL: function(stateId) {
- const activityId = this.get('activityId');
- const agent = this.get('actor');
- const registration = this.get('registration');
- let url = this.xAPIWrapper.lrs.endpoint + 'activities/state?activityId=' + encodeURIComponent(activityId) + '&agent=' + encodeURIComponent(JSON.stringify(agent));
-
- if (registration) url += '®istration=' + encodeURIComponent(registration);
- if (stateId) url += '&stateId=' + encodeURIComponent(stateId);
-
- return url;
- },
-
- _fetchState: function(stateId, callback) {
- const scope = this;
-
- fetch(this._getStateURL(stateId), {
- // cache: "no-cache",
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: this.xAPIWrapper.lrs.auth,
- 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion,
- 'Cache-Control': 'no-cache',
- Pragma: 'no-cache'
- }
- }).then(function(response) {
- if (!response.ok) throw Error(response.statusText);
+ if (callback) callback();
+ });
+ }
- return response.json();
- }).then(function(data) {
- // if (data) Adapt.log.debug(data);
+ _getStateURL(stateId) {
+ const activityId = this.get('activityId');
+ const agent = this.get('actor');
+ const registration = this.get('registration');
+ const { endpoint } = this.xAPIWrapper.lrs;
- if (callback) callback(null, data);
- }).catch(function(error) {
- scope.showErrorNotification();
+ let url = this.xAPIWrapper.lrs.endpoint + 'activities/state?activityId=' + encodeURIComponent(activityId) + '&agent=' + encodeURIComponent(JSON.stringify(agent));
- if (callback) callback();
- });
- },
+ if (registration) url += '®istration=' + encodeURIComponent(registration);
+ if (stateId) url += '&stateId=' + encodeURIComponent(stateId);
- _getStates: function(callback) {
- const scope = this;
+ return url;
+ }
- Adapt.wait.begin();
+ _fetchState(stateId, callback) {
+ const scope = this;
- this._fetchState(null, function(err, data) {
- if (err) {
- scope.showErrorNotification();
+ fetch(this._getStateURL(stateId), {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: this.xAPIWrapper.lrs.auth,
+ 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion,
+ 'Cache-Control': 'no-cache',
+ Pragma: 'no-cache'
+ }
+ }).then(function(response) {
+ if (!response.ok) throw Error(response.statusText);
- if (callback) callback(err, null);
- } else {
- if (callback) callback(null, data);
- }
+ return response.json();
+ }).then(function(data) {
+ if (data) Adapt.log.debug(data);
- Adapt.wait.end();
- });
- },
+ if (callback) callback(null, data);
+ }).catch(function(error) {
+ console.error('Error fetching data:', error);
+ scope.showErrorNotification();
- _getQueueById: function(id) {
- let queue = this._queues[id];
+ if (callback) callback();
+ });
+ }
- if (!queue) {
- queue = this._queues[id] = Async.queue(_.bind(function(id, callback) {
- this.save(id, callback);
- }, this), 1);
+ _getStates(callback) {
+ const scope = this;
- queue.drain = function() {
- Adapt.log.debug('State API queue cleared for ' + id);
- };
+ wait.begin();
+
+ this._fetchState(null, function(err, data) {
+ if (err) {
+ scope.showErrorNotification();
+
+ if (callback) callback(err, null);
+ } else {
+ if (callback) callback(null, data);
}
- return queue;
- },
+ wait.end();
+ });
+ }
- _restoreComponentsData: function() {
- this._restoreDataForState(this.get(COMPONENTS_KEY), Adapt.components.models);
- },
+ _getQueueById(id) {
+ let queue = this._queues[id];
- _restoreDurationsData: function() {
- const models = [Adapt.course].concat(Adapt.contentObjects.models);
+ if (!queue) {
+ queue = this._queues[id] = Async.queue(_.bind(function(id, callback) {
+ this.save(id, callback);
+ }, this), 1);
- this._restoreDataForState(this.get(DURATIONS_KEY), models);
- },
+ queue.drain = function() {
+ Adapt.log.debug('State API queue cleared for ' + id);
+ };
+ }
- _restoreDataForState: function(state, models) {
- if (state.length > 0) {
- state.forEach(function(data) {
- const model = models.filter(function(model) {
- return model.get('_id') === data._id;
- })[0];
+ return queue;
+ }
- // account for models being removed in content without xAPI activityId or registration being changed
- if (model) {
- const restoreData = _.omit(data, '_id');
+ _restoreComponentsData() {
+ this._restoreDataForState(this.get(COMPONENTS_KEY), Adapt.components.models);
+ }
- model.set(restoreData);
- }
- });
- }
- },
-
- _setComponentsData: function(model, data) {
- const stateId = COMPONENTS_KEY;
- const state = this.get(stateId);
- const modelId = model.get('_id');
- const modelIndex = this._getStateModelIndexFor(state, modelId);
-
- // responses won't properly be restored until https://github.com/adaptlearning/adapt_framework/issues/2522 is resolved
- if (model.get('_isQuestionType') && !this._tracking._storeQuestionResponses) {
- delete data._isInteractionComplete;
- delete data._userAnswer;
- delete data._isSubmitted;
- delete data._score;
- delete data._isCorrect;
- delete data._attemptsLeft;
- }
+ _restoreDurationsData() {
+ const models = [Adapt.course].concat(Adapt.contentObjects.models);
- (modelIndex === null) ? state.push(data) : state[modelIndex] = data;
+ this._restoreDataForState(this.get(DURATIONS_KEY), models);
+ }
- this.set(stateId, state);
- },
+ _restoreDataForState(state, models) {
+ if (state.length > 0) {
+ state.forEach(function(data) {
+ const model = models.filter(function(model) {
+ return model.get('_id') === data._id;
+ })[0];
- _setDurationsData: function(model) {
- const stateId = DURATIONS_KEY;
- const state = this.get(stateId);
- const modelId = model.get('_id');
- const modelIndex = this._getStateModelIndexFor(state, modelId);
+ // account for models being removed in content without xAPI activityId or registration being changed
+ if (model) {
+ const restoreData = _.omit(data, '_id');
- const data = {
- _id: modelId,
- _totalDuration: model.get('_totalDuration')
- };
+ model.set(restoreData);
+ }
+ });
+ }
+ }
+
+ _setComponentsData(model, data) {
+ const stateId = COMPONENTS_KEY;
+ const state = this.get(stateId);
+ const modelId = model.get('_id');
+ const modelIndex = this._getStateModelIndexFor(state, modelId);
+
+ // responses won't properly be restored until https://github.com/adaptlearning/adapt_framework/issues/2522 is resolved
+ if (model.get('_isQuestionType') && !this._tracking._storeQuestionResponses) {
+ delete data._isInteractionComplete;
+ delete data._userAnswer;
+ delete data._isSubmitted;
+ delete data._score;
+ delete data._isCorrect;
+ delete data._attemptsLeft;
+ }
- (modelIndex === null) ? state.push(data) : state[modelIndex] = data;
+ (modelIndex === null) ? state.push(data) : state[modelIndex] = data;
- this.set(stateId, state);
- },
+ this.set(stateId, state);
+ }
- _getStateModelIndexFor: function(state, modelId) {
- for (let i = 0, l = state.length; i < l; i++) {
- const stateModel = state[i];
- if (stateModel._id === modelId) return i;
- }
+ _setDurationsData(model) {
+ const stateId = DURATIONS_KEY;
+ const state = this.get(stateId);
+ const modelId = model.get('_id');
+ const modelIndex = this._getStateModelIndexFor(state, modelId);
- return null;
- },
+ const data = {
+ _id: modelId,
+ _totalDuration: model.get('_totalDuration')
+ };
- onDataReady: function() {
- Adapt.wait.queue(_.bind(function() {
- this.restore();
- }, this));
- },
+ (modelIndex === null) ? state.push(data) : state[modelIndex] = data;
- onAdaptInitialize: function() {
- this.setupListeners();
+ this.set(stateId, state);
+ }
- this._isInitialized = true;
- },
+ _getStateModelIndexFor(state, modelId) {
+ for (let i = 0, l = state.length; i < l; i++) {
+ const stateModel = state[i];
+ if (stateModel._id === modelId) return i;
+ }
- onDurationChange: function(model) {
- this._setDurationsData(model);
- },
+ return null;
+ }
- onTrackableStateChange: function(model, state) {
- if (model.get('_type') !== 'component') return;
+ onDataReady() {
+ const config = Adapt.config.get('_xapi');
+ if (config?._isRestoreEnabled === false) return;
+ wait.queue(_.bind(function() {
+ this.restore();
+ }, this));
+ }
- // don't actually need state._isCorrect and state._score for questions, but save trackable state as provided
- this._setComponentsData(model, state);
- },
+ onAdaptInitialize() {
+ this.setupListeners();
- onStateReset: function() {
- this.reset();
- },
+ this._isInitialized = true;
+ }
- // @todo: resetting could go against cmi5 spec, if course was previously completed - can't send multiple "cmi.defined" statements for some verbs
- onLanguageChanged: function(lang, isStateReset) {
- if (this._isInitialized) this.removeModelListeners();
+ onDurationChange(model) {
+ this._setDurationsData(model);
+ }
- this._isRestored = false;
+ onTrackableStateChange(model, state) {
+ if (model.get('_type') !== 'component') return;
- if (!isStateReset) return;
+ // don't actually need state._isCorrect and state._score for questions, but save trackable state as provided
+ this._setComponentsData(model, state);
+ }
- const scope = this;
+ onStateReset() {
+ this.reset();
+ }
- this._getStates(function(err, data) {
- if (err) {
- scope.showErrorNotification();
- } else {
- Adapt.wait.begin();
+ // @todo: resetting could go against cmi5 spec, if course was previously completed - can't send multiple "cmi.defined" statements for some verbs
+ onLanguageChanged(lang, isStateReset) {
+ if (this._isInitialized) this.removeModelListeners();
- const states = data;
+ this._isRestored = false;
- const statesToReset = states.filter(function(id) {
- return id !== 'lang';
- });
+ if (!isStateReset) return;
- Async.each(statesToReset, function(id, callback) {
- scope.delete(id, callback);
- }, function(err) {
- if (err) scope.showErrorNotification();
+ const scope = this;
- const data = {};
- data[COMPONENTS_KEY] = [];
- data[DURATIONS_KEY] = [];
- scope.set(data, { silent: true });
+ this._getStates(function(err, data) {
+ if (err) {
+ scope.showErrorNotification();
+ } else {
+ wait.begin();
- Adapt.wait.end();
- });
- }
- });
- }
+ const states = data;
+
+ const statesToReset = states.filter(function(id) {
+ return id !== 'lang';
+ });
- });
+ Async.each(statesToReset, function(id, callback) {
+ scope.delete(id, callback);
+ }, function(err) {
+ if (err) scope.showErrorNotification();
+
+ const data = {};
+ data[COMPONENTS_KEY] = [];
+ data[DURATIONS_KEY] = [];
+ scope.set(data, { silent: true });
+
+ wait.end();
+ });
+ }
+ });
+ }
- return StateModel;
+}
-});
+export default StateModel;
diff --git a/js/statementModel.js b/js/statementModel.js
index 9cc1101..237583b 100644
--- a/js/statementModel.js
+++ b/js/statementModel.js
@@ -1,442 +1,541 @@
-define([
- 'core/js/adapt',
- 'core/js/enums/completionStateEnum',
- './statements/initializedStatementModel',
- './statements/terminatedStatementModel',
- './statements/preferredLanguageStatementModel',
- './statements/completedStatementModel',
- './statements/experiencedStatementModel',
- './statements/mcqStatementModel',
- './statements/sliderStatementModel',
- './statements/confidenceSliderStatementModel',
- './statements/textInputStatementModel',
- './statements/matchingStatementModel',
- './statements/assessmentStatementModel',
- './statements/resourceItemStatementModel',
- './statements/favouriteStatementModel',
- './statements/unfavouriteStatementModel'
-], function(Adapt, COMPLETION_STATE, InitializedStatementModel, TerminatedStatementModel, PreferredLanguageStatementModel, CompletedStatementModel, ExperiencedStatementModel, McqStatementModel, SliderStatementModel, ConfidenceSliderStatementModel, TextInputStatementModel, MatchingStatementModel, AssessmentStatementModel, ResourceItemStatementModel, FavouriteStatementModel, UnfavouriteStatementModel) {
-
- const StatementModel = Backbone.Model.extend({
-
- _tracking: {
- _questionInteractions: true,
- _assessmentsCompletion: false,
- _assessmentCompletion: true
- },
-
- xAPIWrapper: null,
- _isInitialized: false,
- _hasLanguageChanged: false,
- _courseSessionStartTime: null,
- _currentPageModel: null,
- _terminate: false,
-
- initialize: function(attributes, options) {
+import Adapt from "core/js/adapt";
+import InitializedStatementModel from "./statements/initializedStatementModel";
+import TerminatedStatementModel from "./statements/terminatedStatementModel";
+import LanguageStatementModel from "./statements/languageStatementModel";
+import Visua11yStatementModel from "./statements/visua11yStatementModel";
+import CompletedStatementModel from "./statements/completedStatementModel";
+import ExperiencedStatementModel from "./statements/experiencedStatementModel";
+import InteractedStatementModel from "./statements/interactedStatementModel";
+import ReceivedStatementModel from "./statements/receivedStatementModel";
+import McqStatementModel from "./statements/mcqStatementModel";
+import SliderStatementModel from "./statements/sliderStatementModel";
+import ConfidenceSliderStatementModel from "./statements/confidenceSliderStatementModel";
+import TextInputStatementModel from "./statements/textInputStatementModel";
+import MatchingStatementModel from "./statements/matchingStatementModel";
+import AssessmentStatementModel from "./statements/assessmentStatementModel";
+import AccessedStatementModel from "./statements/accessedStatementModel";
+import ViewedStatementModel from "./statements/viewedStatementModel";
+
+class StatementModel extends Backbone.Model {
+
+ defaults() {
+ return {
+ _tracking: {
+ _questionInteractions: true,
+ _assessmentsCompletion: false,
+ _assessmentCompletion: true,
+ _navbar: true,
+ _trackingErrors: true,
+ _visua11y: false
+ },
+ xAPIWrapper: null,
+ _isInitialized: false,
+ _hasLanguageChanged: false,
+ _courseSessionStartTime: null,
+ _currentPageModel: null,
+ _terminate: false,
+ };
+ }
+
+ initialize(attributes, options) {
+ this.listenTo(Adapt, {
+ 'adapt:initialize': this.onAdaptInitialize,
+ 'xapi:languageChanged': this.onLanguageChanged
+ });
+
+ // Instance Variables
+ this._tracking = {
+ ...this.defaults()._tracking,
+ ...options._tracking
+ };
+
+ this.xAPIWrapper = options.wrapper;
+
+ _.extend(this._tracking, options._tracking);
+ }
+
+ setupListeners() {
+ this.setupModelListeners();
+
+ // don't create new listeners for those which are still valid from initial course load
+ if (this._isInitialized) return;
+
+ this._onVisibilityChange = _.bind(this.onVisibilityChange, this);
+ $(document).on('visibilitychange', this._onVisibilityChange);
+
+ this._onWindowUnload = _.bind(this.onWindowUnload, this);
+ $(window).on('beforeunload unload', this._onWindowUnload);
+
+ this.listenTo(Adapt, {
+ 'pageView:ready': this.onPageViewReady,
+ 'router:location': this.onRouterLocation,
+ 'resources:itemClicked': this.onResourceClicked,
+ 'glossary:termSelected': this.onGlossaryClicked,
+ 'tracking:complete': this.onTrackingComplete
+ });
+
+ if (this._tracking._questionInteractions) {
this.listenTo(Adapt, {
- 'adapt:initialize': this.onAdaptInitialize,
- 'xapi:languageChanged': this.onLanguageChanged
+ 'questionView:recordInteraction': this.onQuestionInteraction
});
+ }
- this.xAPIWrapper = options.wrapper;
+ // @todo: if only 1 Adapt.assessment._assessments, override so we never record both statements - leave to config.json for now?
+ if (this._tracking._assessmentsCompletion) {
+ this.listenTo(Adapt, {
+ 'assessments:complete': this.onAssessmentsComplete
+ });
+ }
- _.extend(this._tracking, options._tracking);
+ if (this._tracking._assessmentCompletion) {
+ this.listenTo(Adapt, {
+ 'assessment:complete': this.onAssessmentComplete
+ });
+ }
+
+ if (this._tracking._navbar) {
+ this.listenTo(Adapt, {
+ 'help:opened': this.onHelpOpened,
+ 'navigation:toggleDrawer': this.onDrawerOpened,
+ 'pageLevelProgress:toggleDrawer': this.onPLPDrawerOpened
+ });
+ }
+
+ if (this._tracking._visua11y) {
+ this.listenTo(Adapt, {
+ 'visua11y:opened': this.onVisua11yOpened,
+ 'visua11y:toggle': this.onVisua11yToggle
+ });
+ }
+
+ if (this._tracking._trackingErrors) {
+ this.listenTo(Adapt, {
+ 'tracking:initializeError': this.onInitializeError,
+ 'tracking:dataError': this.onDataError,
+ 'tracking:connectionError': this.onConnectionError,
+ 'tracking:terminationError': this.onTerminationError
+ });
+ }
+ }
- // this.loadRecipe();
- },
+ setupModelListeners() {
+ this.listenTo(Adapt.contentObjects, {
+ 'change:_isComplete': this.onContentObjectComplete
+ });
- loadRecipe: function() {
+ this.listenTo(Adapt.components, {
+ 'change:_isComplete': this.onComponentComplete
+ });
+ }
- },
+ removeModelListeners() {
+ this.stopListening(Adapt.contentObjects, {
+ 'change:_isComplete': this.onContentObjectComplete
+ });
+
+ this.stopListening(Adapt.components, {
+ 'change:_isComplete': this.onComponentComplete
+ });
+ }
+
+ showErrorNotification() {
+ Adapt.trigger('xapi:lrsError');
+ }
+
+ sendInitialized() {
+ const config = this.attributes;
+ const statementModel = new InitializedStatementModel(config);
+ const statement = statementModel.getData(Adapt.course);
+
+ this.send(statement);
+ }
+
+ sendTerminated() {
+ const model = Adapt.course;
+
+ this.setModelDuration(model);
+
+ const config = this.attributes;
+ const statementModel = new TerminatedStatementModel(config);
+ const statement = statementModel.getData(model);
+
+ this.send(statement);
+ }
+
+ sendPreferredLanguage() {
+ const config = this.attributes;
+ const statementModel = new LanguageStatementModel(config);
+ const statement = statementModel.getData(Adapt.course, Adapt.config.get('_activeLanguage'));
+
+ this.send(statement);
+ }
+
+ sendCompleted(model) {
+ const modelType = model.get('_type');
+ if (modelType === 'course' || modelType === 'page') this.setModelDuration(model);
+
+ const config = this.attributes;
+ const statementModel = new CompletedStatementModel(config);
+ const statement = statementModel.getData(model);
+
+ this.send(statement);
+ }
+
+ sendExperienced(model) {
+ this.setModelDuration(model);
+
+ const config = this.attributes;
+ const statementModel = new ExperiencedStatementModel(config);
+ const statement = statementModel.getData(model);
+
+ this.send(statement);
+
+ model.unset('_sessionStartTime', { silent: true });
+ model.unset('_sessionDuration', { silent: true });
+ }
+
+ sendInteracted(type) {
+ const model = Adapt.course;
+
+ const config = this.attributes;
+ const statementModel = new InteractedStatementModel(config, { _type: type });
+ const statement = statementModel.getData(model);
+
+ this.send(statement);
+ }
+
+ sendReceived(type) {
+ const model = Adapt.course;
+
+ const config = this.attributes;
+ const statementModel = new ReceivedStatementModel(config, { _type: type });
+ const statement = statementModel.getData(model);
+
+ this.send(statement);
+ }
+
+ sendQuestionAnswered(model) {
+ const config = this.attributes;
+ const questionType = model.get('_component');
+ let statementClass;
+
+ // better solution than this factory type pattern?
+ switch (questionType) {
+ case 'mcq':
+ case 'gmcq':
+ statementClass = McqStatementModel;
+ break;
+ case 'slider':
+ statementClass = SliderStatementModel;
+ break;
+ case 'confidenceSlider':
+ statementClass = ConfidenceSliderStatementModel;
+ break;
+ case 'textinput':
+ statementClass = TextInputStatementModel;
+ break;
+ case 'matching':
+ statementClass = MatchingStatementModel;
+ break;
+ }
- setupListeners: function() {
- this.setupModelListeners();
+ const statementModel = new statementClass(config);
+ const statement = statementModel.getData(model);
- // don't create new listeners for those which are still valid from initial course load
- if (this._isInitialized) return;
+ this.send(statement);
+ }
- this._onVisibilityChange = _.bind(this.onVisibilityChange, this);
- $(document).on('visibilitychange', this._onVisibilityChange);
+ sendAssessmentCompleted(model, state) {
+ const config = this.attributes;
+ const statementModel = new AssessmentStatementModel(config);
+ const statement = statementModel.getData(model, state);
- this._onWindowUnload = _.bind(this.onWindowUnload, this);
- $(window).on('beforeunload unload', this._onWindowUnload);
+ this.send(statement);
+ }
- this.listenTo(Adapt, {
- 'pageView:ready': this.onPageViewReady,
- 'router:location': this.onRouterLocation,
- 'resources:itemClicked': this.onResourceClicked,
- 'tracking:complete': this.onTrackingComplete
- });
+ sendResourceAccessed(model) {
+ const config = this.attributes;
+ const statementModel = new AccessedStatementModel(config);
+ const statement = statementModel.getData(model);
- if (this._tracking._questionInteractions) {
- this.listenTo(Adapt, {
- 'questionView:recordInteraction': this.onQuestionInteraction
- });
- }
+ this.send(statement);
+ }
- // @todo: if only 1 Adapt.assessment._assessments, override so we never record both statements - leave to config.json for now?
- if (this._tracking._assessmentsCompletion) {
- this.listenTo(Adapt, {
- 'assessments:complete': this.onAssessmentsComplete
- });
- }
+ sendGlossaryViewed(model) {
+ const config = this.attributes;
+ const statementModel = new ViewedStatementModel(config);
+ const statement = statementModel.getData(model);
- if (this._tracking._assessmentCompletion) {
- this.listenTo(Adapt, {
- 'assessment:complete': this.onAssessmentComplete
- });
- }
- },
+ this.send(statement);
+ }
- setupModelListeners: function() {
- this.listenTo(Adapt.contentObjects, {
- 'change:_isComplete': this.onContentObjectComplete
- });
+ sendVisua11yPreference(name, state) {
+ const model = Adapt.course;
- this.listenTo(Adapt.components, {
- 'change:_isComplete': this.onComponentComplete
- });
- },
+ const config = this.attributes;
+ const statementModel = new Visua11yStatementModel(config, { _name: name, _state: state });
+ const statement = statementModel.getData(model);
- removeModelListeners: function() {
- this.stopListening(Adapt.contentObjects, {
- 'change:_isComplete': this.onContentObjectComplete
- });
+ this.send(statement);
+ }
- this.stopListening(Adapt.components, {
- 'change:_isComplete': this.onComponentComplete
- });
- },
-
- showErrorNotification: function() {
- Adapt.trigger('xapi:lrsError');
- },
-
- sendInitialized: function() {
- const config = this.attributes;
- const statementModel = new InitializedStatementModel(config);
- const statement = statementModel.getData(Adapt.course);
-
- this.send(statement);
- },
-
- sendTerminated: function() {
- const model = Adapt.course;
-
- this.setModelDuration(model);
-
- const config = this.attributes;
- const statementModel = new TerminatedStatementModel(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
- },
-
- sendPreferredLanguage: function() {
- const config = this.attributes;
- const statementModel = new PreferredLanguageStatementModel(config);
- const statement = statementModel.getData(Adapt.course, Adapt.config.get('_activeLanguage'));
-
- this.send(statement);
- },
-
- sendCompleted: function(model) {
- const modelType = model.get('_type');
- if (modelType === 'course' || modelType === 'page') this.setModelDuration(model);
-
- const config = this.attributes;
- const statementModel = new CompletedStatementModel(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
- },
-
- sendExperienced: function(model) {
- this.setModelDuration(model);
-
- const config = this.attributes;
- const statementModel = new ExperiencedStatementModel(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
-
- model.unset('_sessionStartTime', { silent: true });
- model.unset('_sessionDuration', { silent: true });
- },
-
- sendQuestionAnswered: function(model) {
- const config = this.attributes;
- const questionType = model.get('_component');
- let statementClass;
-
- // better solution than this factory type pattern?
- switch (questionType) {
- case 'mcq':
- case 'gmcq':
- statementClass = McqStatementModel;
- break;
- case 'slider':
- statementClass = SliderStatementModel;
- break;
- case 'confidenceSlider':
- statementClass = ConfidenceSliderStatementModel;
- break;
- case 'textinput':
- statementClass = TextInputStatementModel;
- break;
- case 'matching':
- statementClass = MatchingStatementModel;
- break;
- }
+ /*
+ * @todo: Add Fetch API into xAPIWrapper - https://github.com/adlnet/xAPIWrapper/issues/166
+ */
+ send(statement) {
+ const config = Adapt.config.get('_xapi');
- const statementModel = new statementClass(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
- },
-
- sendAssessmentCompleted: function(model, state) {
- const config = this.attributes;
- const statementModel = new AssessmentStatementModel(config);
- const statement = statementModel.getData(model, state);
-
- this.send(statement);
- },
-
- sendResourceExperienced: function(model) {
- const config = this.attributes;
- const statementModel = new ResourceItemStatementModel(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
- },
-
- sendFavourite: function(model) {
- const config = this.attributes;
- const statementModel = new FavouriteStatementModel(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
- },
-
- sendUnfavourite: function(model) {
- const config = this.attributes;
- const statementModel = new UnfavouriteStatementModel(config);
- const statement = statementModel.getData(model);
-
- this.send(statement);
- },
-
- /*
- * @todo: Add Fetch API into xAPIWrapper - https://github.com/adlnet/xAPIWrapper/issues/166
- */
- send: function(statement) {
- const lrs = this.xAPIWrapper.lrs;
- const url = lrs.endpoint + 'statements';
- const data = JSON.stringify(statement);
- const scope = this;
-
- fetch(url, {
- keepalive: this._terminate,
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: lrs.auth,
- 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion
- },
- body: data
- }).then(function(response) {
- Adapt.log.debug('[' + statement.id + ']: ' + response.status + ' - ' + response.statusText);
-
- if (!response.ok) throw Error(response.statusText);
-
- return response;
- }).catch(function(error) {
- scope.showErrorNotification();
- });
- },
+ if (config?._isDebugModeEnabled) {
+ console.log(statement);
+ return;
+ }
- setModelSessionStartTime: function(model, restoredTime) {
- const time = restoredTime || new Date().getTime();
+ const lrs = this.xAPIWrapper.lrs;
+ const url = lrs.endpoint + 'statements';
+ const data = JSON.stringify(statement);
+ const scope = this;
- model.set('_sessionStartTime', time);
+ fetch(url, {
+ keepalive: this._terminate,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: lrs.auth,
+ 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion
+ },
+ body: data
+ }).then(function(response) {
+ Adapt.log.debug('[' + statement.id + ']: ' + response.status + ' - ' + response.statusText);
- // capture start time for course session as models are reloaded on a language change
- if (model.get('_type') === 'course') this._courseSessionStartTime = time;
- },
+ if (!response.ok) throw Error(response.statusText);
- setModelDuration: function(model) {
- const elapsedTime = new Date().getTime() - model.get('_sessionStartTime');
+ return response;
+ }).catch(function(error) {
+ scope.showErrorNotification();
+ });
+ }
- // reset `_sessionStartTime` to prevent cumulative additions via multiple calls to this method within the same session - mostly affects course model
- this.setModelSessionStartTime(model);
+ setModelSessionStartTime(model, restoredTime) {
+ const time = restoredTime || new Date().getTime();
- model.set({
- _sessionDuration: (model.get('_sessionDuration') || 0) + elapsedTime,
- _totalDuration: (model.get('_totalDuration') || 0) + elapsedTime
- });
- },
+ model.set('_sessionStartTime', time);
- onLanguageChanged: function(lang, isStateReset) {
- this._hasLanguageChanged = true;
+ // capture start time for course session as models are reloaded on a language change
+ if (model.get('_type') === 'course') this._courseSessionStartTime = time;
+ }
- if (this._isInitialized) {
- this.removeModelListeners();
+ setModelDuration(model) {
+ const elapsedTime = new Date().getTime() - model.get('_sessionStartTime');
- if (this._currentPageModel) {
- // @todo: ideally this would fire before the Adapt collections have reset - not possible in earlier frameworks but might be possible in later by `listenTo('Adapt.data', 'loading')` which fires before reset
- // send experienced statement to ensure statement is sent before preferred language
- this.sendExperienced(this._currentPageModel);
+ // reset `_sessionStartTime` to prevent cumulative additions via multiple calls to this method within the same session - mostly affects course model
+ this.setModelSessionStartTime(model);
- // due to models reloading `_currentPageModel` is not part of Adapt.contentObjects so the stateModel is not picking up the durations change
- Adapt.trigger('xapi:durationsChange', this._currentPageModel);
+ model.set({
+ _sessionDuration: (model.get('_sessionDuration') || 0) + elapsedTime,
+ _totalDuration: (model.get('_totalDuration') || 0) + elapsedTime
+ });
+ }
- // reset to bypass call in `onRouterLocation` so experienced statement is not sent
- this._currentPageModel = null;
- }
+ onLanguageChanged(lang, isStateReset) {
+ this._hasLanguageChanged = true;
- // restore course session start time
- if (!isStateReset) this.setModelSessionStartTime(Adapt.course, this._courseSessionStartTime);
+ if (this._isInitialized) {
+ this.removeModelListeners();
- // send statement if language has changed since the course was started - call in `onAdaptInitialize` is only used initially to ensure correct execution order of statements
- this.sendPreferredLanguage();
+ if (this._currentPageModel) {
+ // @todo: ideally this would fire before the Adapt collections have reset - not possible in earlier frameworks but might be possible in later by `listenTo('Adapt.data', 'loading')` which fires before reset
+ // send experienced statement to ensure statement is sent before preferred language
+ this.sendExperienced(this._currentPageModel);
+
+ // due to models reloading `_currentPageModel` is not part of Adapt.contentObjects so the stateModel is not picking up the durations change
+ Adapt.trigger('xapi:durationsChange', this._currentPageModel);
+
+ // reset to bypass call in `onRouterLocation` so experienced statement is not sent
+ this._currentPageModel = null;
}
- this.set('lang', lang);
+ // restore course session start time
+ if (!isStateReset) this.setModelSessionStartTime(Adapt.course, this._courseSessionStartTime);
- // reset course session start time if the state has been reset
- if (isStateReset) this.setModelSessionStartTime(Adapt.course);
- },
+ // send statement if language has changed since the course was started - call in `onAdaptInitialize` is only used initially to ensure correct execution order of statements
+ this.sendPreferredLanguage();
+ }
- onAdaptInitialize: function() {
- if (!this._isInitialized) {
- this.setModelSessionStartTime(Adapt.course);
+ this.set('lang', lang);
- this.sendInitialized();
+ // reset course session start time if the state has been reset
+ if (isStateReset) this.setModelSessionStartTime(Adapt.course);
+ }
- // only called on initial launch if the course contains a language picker - call in `onLanguageChanged` is used for subsequent changes within the current browser session
- if (this._hasLanguageChanged) {
- this.sendPreferredLanguage();
+ onAdaptInitialize() {
+ if (!this._isInitialized) {
+ this.setModelSessionStartTime(Adapt.course);
- this._hasLanguageChanged = false;
- }
+ this.sendInitialized();
+
+ // only called on initial launch if the course contains a language picker - call in `onLanguageChanged` is used for subsequent changes within the current browser session
+ if (this._hasLanguageChanged) {
+ this.sendPreferredLanguage();
+
+ this._hasLanguageChanged = false;
}
+ }
- this.setupListeners();
+ this.setupListeners();
- this._isInitialized = true;
- },
+ this._isInitialized = true;
+ }
- onPageViewReady: function(view) {
- const model = view.model;
+ onPageViewReady(view) {
+ const model = view.model;
- // store model so we have a reference to existing model following a language change
- this._currentPageModel = model;
+ // store model so we have a reference to existing model following a language change
+ this._currentPageModel = model;
- this.setModelSessionStartTime(model);
- },
+ this.setModelSessionStartTime(model);
+ }
- onRouterLocation: function() {
- const previousId = Adapt.location._previousId;
+ onRouterLocation() {
+ const previousId = Adapt.location._previousId;
- // bypass if no page model or no previous location
- if (!this._currentPageModel || !previousId) return;
+ // bypass if no page model or no previous location
+ if (!this._currentPageModel || !previousId) return;
- const model = Adapt.findById(previousId);
+ const model = Adapt.findById(previousId);
- if (model && model.get('_type') === 'page') {
- // only record experienced statements for pages
- this.sendExperienced(model);
- }
+ if (model && model.get('_type') === 'page') {
+ // only record experienced statements for pages
+ this.sendExperienced(model);
+ }
- this._currentPageModel = null;
- },
+ this._currentPageModel = null;
+ }
- onContentObjectComplete: function(model) {
- // since Adapt 5.5 the course model is treated as a contentObject - ignore as this is already handled by `onTrackingComplete`
- if (model.get('_type') === 'course') return;
+ onContentObjectComplete(model) {
+ // since Adapt 5.5 the course model is treated as a contentObject - ignore as this is already handled by `onTrackingComplete`
+ if (model.get('_type') === 'course') return;
- // @todo: if page contains an assessment which can be reset but the page completes regardless of pass/fail, the `_totalDuration` will increase cumulatively for each attempt - should we reset the duration when reset?
- if (model.get('_isComplete') && !model.get('_isOptional')) {
- this.sendCompleted(model);
- }
- },
+ // @todo: if page contains an assessment which can be reset but the page completes regardless of pass/fail, the `_totalDuration` will increase cumulatively for each attempt - should we reset the duration when reset?
+ if (model.get('_isComplete') && !model.get('_isOptional')) {
+ this.sendCompleted(model);
+ }
+ }
- onComponentComplete: function(model) {
- if (model.get('_isComplete') && model.get('_recordCompletion')) {
- this.sendCompleted(model);
- }
- },
-
- onAssessmentsComplete: function(state, model) {
- // defer as triggered before last question triggers questionView:recordInteraction
- _.defer(_.bind(this.sendAssessmentCompleted, this), model, state);
- },
-
- onAssessmentComplete: function(state) {
- // create model based on Adapt.course._assessment, otherwise use Adapt.course as base
- let model;
- const assessmentConfig = Adapt.course.get('_assessment');
-
- if (assessmentConfig && assessmentConfig._id && assessmentConfig.title) {
- model = new Backbone.Model(assessmentConfig);
- } else {
- model = Adapt.course;
- }
+ onComponentComplete(model) {
+ if (model.get('_isComplete') && model.get('_recordCompletion')) {
+ this.sendCompleted(model);
+ }
+ }
- _.defer(_.bind(this.sendAssessmentCompleted, this), model, state);
- },
+ onAssessmentComplete(state) {
+ // create model based on Adapt.course._assessment, otherwise use Adapt.course as base
+ let model;
+ const assessmentConfig = Adapt.course.get('_assessment');
- onTrackingComplete: function(completionData) {
- this.sendCompleted(Adapt.course);
+ if (assessmentConfig && assessmentConfig._id && assessmentConfig.title) {
+ model = new Backbone.Model(assessmentConfig);
+ } else {
+ model = Adapt.course;
+ }
- // no need to use completionData.assessment due to assessment:complete listener, which isn't restricted to only firing on tracking:complete
- },
+ _.defer(_.bind(this.sendAssessmentCompleted, this), model, state);
+ }
- onQuestionInteraction: function(view) {
- this.sendQuestionAnswered(view.model);
- },
+ onAssessmentsComplete(state, model) {
+ // defer as triggered before last question triggers questionView:recordInteraction
+ _.defer(_.bind(this.sendAssessmentCompleted, this), model, state);
+ }
- onResourceClicked: function(data) {
- const model = new Backbone.Model();
+ onTrackingComplete() {
+ this.sendCompleted(Adapt.course);
- model.set({
- _id: (data.type === 'document') ? data.filename : '?link=' + data._link,
- title: data.title,
- description: data.description,
- url: (data.type === 'document') ? data.filename : data._link
- });
+ // no need to use completionData.assessment due to assessment:complete listener, which isn't restricted to only firing on tracking:complete
+ }
- this.sendResourceExperienced(model);
- },
+ onQuestionInteraction(view) {
+ this.sendQuestionAnswered(view.model);
+ }
- onVisibilityChange: function() {
- // set durations to ensure State loss is minimised for durations data, if terminate didn't fire
- if (document.visibilityState === 'hidden' && !this._terminate) {
- if (this._currentPageModel) this.setModelDuration(this._currentPageModel);
+ onResourceClicked(data) {
+ const model = new Backbone.Model();
- this.setModelDuration(Adapt.course);
- }
- },
+ model.set({
+ _id: (data.type === 'document') ? data.filename : '?link=' + data._link,
+ title: data.title,
+ description: data.description,
+ url: (data.type === 'document') ? data.filename : data._link
+ });
- onWindowUnload: function() {
- $(window).off('beforeunload unload', this._onWindowUnload);
+ this.sendResourceAccessed(model);
+ }
- if (!this._terminate) {
- Adapt.terminate = this._terminate = true;
+ onGlossaryClicked(data) {
+ const model = new Backbone.Model();
- const model = Adapt.findById(Adapt.location._currentId);
+ model.set({
+ term: data.attributes.term,
+ description: data.attributes.description
+ });
- if (model && model.get('_type') !== 'course') {
- this.sendExperienced(model);
- }
+ this.sendGlossaryViewed(model);
+ }
- this.sendTerminated();
- }
+ onDrawerOpened() {
+ this.sendInteracted('drawer');
+ }
+
+ onPLPDrawerOpened() {
+ this.sendInteracted('pageLevelProgress');
+ }
+
+ onVisua11yOpened() {
+ this.sendInteracted('accessibility');
+ }
+
+ onVisua11yToggle(model, name, state) {
+ this.sendVisua11yPreference(model, name, state);
+ }
+
+ onInitializeError() {
+ this.sendReceived('Initialization Error');
+ }
+
+ onDataError() {
+ this.sendReceived('Data Error');
+ }
+
+ onConnectionError() {
+ this.sendReceived('Connection Error');
+ }
+
+ onTerminationError() {
+ this.sendReceived('Termination Error');
+ }
+
+ onVisibilityChange() {
+ // set durations to ensure State loss is minimised for durations data, if terminate didn't fire
+ if (document.visibilityState === 'hidden' && !this._terminate) {
+ if (this._currentPageModel) this.setModelDuration(this._currentPageModel);
+
+ this.setModelDuration(Adapt.course);
}
+ }
+
+ onWindowUnload() {
+ $(window).off('beforeunload unload', this._onWindowUnload);
+
+ if (!this._terminate) {
+ Adapt.terminate = this._terminate = true;
- });
+ const model = Adapt.findById(Adapt.location._currentId);
- return StatementModel;
+ if (model && model.get('_type') !== 'course') {
+ this.sendExperienced(model);
+ }
+
+ this.sendTerminated();
+ }
+ }
+}
-});
+export default StatementModel;
diff --git a/js/statements/interactedStatementModel.js b/js/statements/interactedStatementModel.js
new file mode 100644
index 0000000..8adda66
--- /dev/null
+++ b/js/statements/interactedStatementModel.js
@@ -0,0 +1,38 @@
+import AbstractStatementModel from "./abstractStatementModel";
+
+class InteractedStatementModel extends AbstractStatementModel {
+
+ initialize(attributes, options) {
+ this._type = null;
+ this._type = options._type;
+
+ AbstractStatementModel.prototype.initialize.apply(this, arguments);
+ }
+
+ getVerb(model) {
+ const verb = {
+ id: 'http://adlnet.gov/expapi/verbs/interacted',
+ display: {}
+ };
+
+ verb.display[super.get('recipeLang')] = 'interacted';
+
+ return verb;
+ }
+
+ getActivityType(model) {
+ return ADL.activityTypes.interaction;
+ }
+
+ getContextExtensions(model) {
+ const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments);
+
+ _.extend(extensions, {
+ 'http://id.tincanapi.com/extension/condition-type': this._type
+ });
+
+ return extensions;
+ }
+}
+
+export default InteractedStatementModel;
diff --git a/js/statements/languageStatementModel.js b/js/statements/languageStatementModel.js
new file mode 100644
index 0000000..7afda8f
--- /dev/null
+++ b/js/statements/languageStatementModel.js
@@ -0,0 +1,26 @@
+import PreferredStatementModel from './preferredStatementModel';
+
+class LanguageStatementModel extends PreferredStatementModel {
+
+ getData(model, lang) {
+ const statement = PreferredStatementModel.prototype.getData.apply(this, arguments);
+
+ statement.result = this.getResult(model, lang);
+
+ return statement;
+ }
+
+ getActivityType(model) {
+ return ADL.activityTypes.course;
+ }
+
+ getResult(model, lang) {
+ const result = {
+ response: lang
+ };
+
+ return result;
+ }
+}
+
+export default LanguageStatementModel;
diff --git a/js/statements/matchingStatementModel.js b/js/statements/matchingStatementModel.js
index b7b7736..3da9aa3 100644
--- a/js/statements/matchingStatementModel.js
+++ b/js/statements/matchingStatementModel.js
@@ -1,40 +1,9 @@
import QuestionStatementModel from './questionStatementModel';
-class MatchingStatementModel extends QuestionStatementModel {
-
- /*
- getInteractionObject(model) {
- var interactionObject = model.getInteractionObject();
-
- var definition = {
- source: this.getSource(interactionObject.source),
- target: this.getTarget(interactionObject.target),
- correctResponsesPattern: interactionObject.correctResponsesPattern
- };
-
- return definition;
- }
-
- getSource(sources) {
- sources.forEach(function(source) {
- var description = {};
- description[this.get('lang')] = source.description;
- source.description = description;
- }, this);
-
- return sources;
- }
+const ITEM_DELIMETER = '[,]';
+const PAIR_DELIMETER = '[,]';
- getTarget(targets) {
- targets.forEach(function(target) {
- var description = {};
- description[this.get('lang')] = target.description;
- target.description = description;
- }, this);
-
- return targets;
- }
- */
+class MatchingStatementModel extends QuestionStatementModel {
getResponse(model) {
return model.getResponse().replace(/\./g, ITEM_DELIMETER).replace(/,|#/g, PAIR_DELIMETER);
@@ -43,57 +12,3 @@ class MatchingStatementModel extends QuestionStatementModel {
}
export default MatchingStatementModel;
-
-
-define([
- './questionStatementModel'
-], function(QuestionStatementModel) {
-
- const ITEM_DELIMETER = '[.]';
- const PAIR_DELIMETER = '[,]';
-
- const MatchingStatementModel = QuestionStatementModel.extend({
-
- /*
- getInteractionObject: function(model) {
- var interactionObject = model.getInteractionObject();
-
- var definition = {
- source: this.getSource(interactionObject.source),
- target: this.getTarget(interactionObject.target),
- correctResponsesPattern: interactionObject.correctResponsesPattern
- };
-
- return definition;
- },
-
- getSource: function(sources) {
- sources.forEach(function(source) {
- var description = {};
- description[this.get('lang')] = source.description;
- source.description = description;
- }, this);
-
- return sources;
- },
-
- getTarget: function(targets) {
- targets.forEach(function(target) {
- var description = {};
- description[this.get('lang')] = target.description;
- target.description = description;
- }, this);
-
- return targets;
- },
- */
-
- getResponse: function(model) {
- return model.getResponse().replace(/\./g, ITEM_DELIMETER).replace(/,|#/g, PAIR_DELIMETER);
- }
-
- });
-
- return MatchingStatementModel;
-
-});
diff --git a/js/statements/mcqStatementModel.js b/js/statements/mcqStatementModel.js
index 4aebf2e..c2defa6 100644
--- a/js/statements/mcqStatementModel.js
+++ b/js/statements/mcqStatementModel.js
@@ -1,29 +1,8 @@
import QuestionStatementModel from './questionStatementModel';
-class McqStatementModel extends QuestionStatementModel {
-
- /*
- getInteractionObject(model) {
- var interactionObject = model.getInteractionObject();
-
- var definition = {
- choices: this.getChoices(interactionObject.choices),
- correctResponsesPattern: interactionObject.correctResponsesPattern
- };
+const DELIMETER = '[,]';
- return definition;
- }
-
- getChoices(choices) {
- choices.forEach(function(choice) {
- var description = {};
- description[this.get('lang')] = choice.description;
- choice.description = description;
- }, this);
-
- return choices;
- }
- */
+class McqStatementModel extends QuestionStatementModel {
getResponse(model) {
return model.getResponse().replace(/,|#/g, DELIMETER);
diff --git a/js/statements/questionStatementModel.js b/js/statements/questionStatementModel.js
index 548a8b7..575b9b8 100644
--- a/js/statements/questionStatementModel.js
+++ b/js/statements/questionStatementModel.js
@@ -85,6 +85,16 @@ class QuestionStatementModel extends AbstractStatementModel {
return contextActivities;
}
+ getContextExtensions(model) {
+ const extensions = AbstractStatementModel.prototype.getObjectExtensions.apply(this, arguments);
+
+ _.extend(extensions, {
+ 'http://id.tincanapi.com/extension/attempt-id': this.getAttempt(model)
+ });
+
+ return extensions;
+ }
+
getAssessmentContextActivity(model) {
const assessment = model.findAncestor('articles');
const object = AbstractStatementModel.prototype.getObject.call(this, assessment);
@@ -114,6 +124,10 @@ class QuestionStatementModel extends AbstractStatementModel {
getResponse(model) {
return model.getResponse();
}
+
+ getAttempt(model) {
+ return model.get('_attempts') - model.get('_attemptsLeft');
+ }
}
diff --git a/js/statements/receivedStatementModel.js b/js/statements/receivedStatementModel.js
new file mode 100644
index 0000000..b3c3b96
--- /dev/null
+++ b/js/statements/receivedStatementModel.js
@@ -0,0 +1,45 @@
+import AbstractStatementModel from './abstractStatementModel';
+
+class ReceivedStatementModel extends AbstractStatementModel {
+
+ initialize(attributes, options) {
+ this._type = null;
+ this._type = options._type;
+
+ AbstractStatementModel.prototype.initialize.apply(this, arguments);
+ }
+
+ getVerb(model) {
+ const verb = {
+ id: 'http://activitystrea.ms/schema/1.0/receive',
+ display: {}
+ };
+
+ verb.display[this.get('recipeLang')] = 'received';
+
+ return verb;
+ }
+
+ getActivityType(model) {
+ return ADL.activityTypes.course;
+ }
+
+ getName(model) {
+ const name = {};
+ name[this.get('lang')] = this._type;
+
+ return name;
+ }
+
+ getContextExtensions(model) {
+ const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments);
+
+ _.extend(extensions, {
+ 'http://id.tincanapi.com/extension/condition-type': this._type
+ });
+
+ return extensions;
+ }
+}
+
+export default ReceivedStatementModel;
diff --git a/js/statements/sliderStatementModel.js b/js/statements/sliderStatementModel.js
index 69d02cf..317d00b 100644
--- a/js/statements/sliderStatementModel.js
+++ b/js/statements/sliderStatementModel.js
@@ -1,12 +1,8 @@
import QuestionStatementModel from './questionStatementModel';
-class SliderStatementModel extends QuestionStatementModel {
+const DELIMETER = '[:]';
- defaults() {
- return {
- DELIMETER: '[:]'
- }
- }
+class SliderStatementModel extends QuestionStatementModel {
getInteractionObject(model) {
const definition = {
@@ -26,7 +22,7 @@ class SliderStatementModel extends QuestionStatementModel {
const top = correctRange._top || '';
return [
- bottom + this.DELIMETER + top
+ bottom + DELIMETER + top
];
}
}
diff --git a/js/statements/viewedStatementModel.js b/js/statements/viewedStatementModel.js
new file mode 100644
index 0000000..ce2272e
--- /dev/null
+++ b/js/statements/viewedStatementModel.js
@@ -0,0 +1,58 @@
+import AbstractStatementModel from './abstractStatementModel';
+
+class ViewedStatementModel extends AbstractStatementModel {
+
+ getVerb(model) {
+ const verb = {
+ id: 'http://id.tincanapi.com/verb/viewed',
+ display: {}
+ };
+
+ verb.display[this.get('recipeLang')] = 'viewed';
+
+ return verb;
+ }
+
+ getActivityType(model) {
+ return 'http://id.tincanapi.com/activitytype/vocabulary-word';
+ }
+
+ getName(model) {
+ const name = {};
+ name[this.get('lang')] = model.get('term');
+
+ return name;
+ }
+
+ getDescription(model) {
+ const description = {};
+ description[this.get('lang')] = model.get('description');
+
+ return description;
+ }
+
+ getContextExtensions(model) {
+ const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments);
+
+ _.extend(extensions, {
+ 'http://id.tincanapi.com/extension/tags': {
+ term: model.get('term'),
+ description: model.get('description')
+ }
+ });
+
+ return extensions;
+ }
+
+ getUniqueIri(model) {
+ let iri = this.get('activityId');
+
+ if (model && model.get('_type') !== 'course') {
+ iri += '/glossary';
+ }
+
+ return iri;
+ }
+}
+
+export default ViewedStatementModel;
diff --git a/js/statements/visua11yStatementModel.js b/js/statements/visua11yStatementModel.js
new file mode 100644
index 0000000..71e8a1c
--- /dev/null
+++ b/js/statements/visua11yStatementModel.js
@@ -0,0 +1,47 @@
+import AbstractStatementModel from './abstractStatementModel';
+import PreferredStatementModel from './preferredStatementModel';
+
+class Visua11yStatementModel extends PreferredStatementModel {
+
+ initialize(attributes, options) {
+ this._type = null;
+ this._name = options._name;
+ this._state = options._state;
+
+ AbstractStatementModel.prototype.initialize.apply(this, arguments);
+ }
+
+ getActivityType(model) {
+ return ADL.activityTypes.interaction;
+ }
+
+ getName(model) {
+ const name = {};
+ name[this.get('lang')] = this._name;
+
+ return name;
+ }
+
+ getContextExtensions(model) {
+ const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments);
+
+ _.extend(extensions, {
+ 'http://id.tincanapi.com/extension/condition-type': this._name,
+ 'http://id.tincanapi.com/extension/condition-value': this._state
+ });
+
+ return extensions;
+ }
+
+ getUniqueIri(model) {
+ let iri = this.get('activityId');
+
+ if (model && model.get('_type') !== 'course') {
+ iri += '/visua11y';
+ }
+
+ return iri;
+ }
+}
+
+export default Visua11yStatementModel;
diff --git a/js/utils.js b/js/utils.js
index 15a83eb..667aba2 100644
--- a/js/utils.js
+++ b/js/utils.js
@@ -1,66 +1,61 @@
-define(function() {
-
- const Utils = {
-
- getISO8601Duration: function(milliseconds) {
- const centiseconds = Math.round(milliseconds / 10);
- const hours = parseInt(centiseconds / 360000, 10);
- const minutes = parseInt((centiseconds % 360000) / 6000, 10);
- const seconds = ((centiseconds % 360000) % 6000) / 100;
-
- let durationString = 'PT';
- if (hours > 0) durationString += hours + 'H';
- if (minutes > 0) durationString += minutes + 'M';
- durationString += seconds + 'S';
-
- return durationString;
- },
-
- getTimestamp: function() {
- const date = new Date();
- const ISODate = this.getISODate(date);
- const ISOTime = this.getISOTime(date);
- const ISOOffset = this.getISOOffset(date);
-
- return ISODate + 'T' + ISOTime + ISOOffset;
- },
-
- getISODate: function(date) {
- const year = date.getFullYear();
- const month = this.padZeros(date.getMonth() + 1);
- const monthDay = this.padZeros(date.getDate());
-
- return year + '-' + month + '-' + monthDay;
- },
-
- getISOTime: function(date) {
- const hours = this.padZeros(date.getHours());
- const minutes = this.padZeros(date.getMinutes());
- const seconds = this.padZeros(date.getSeconds());
- const milliseconds = this.padZeros(date.getMilliseconds());
-
- return hours + ':' + minutes + ':' + seconds + '.' + milliseconds;
- },
-
- getISOOffset: function(date) {
- const offset = date.getTimezoneOffset();
-
- if (offset === 0) return 'Z';
-
- const absOffset = Math.abs(offset);
- const offsetHours = this.padZeros(Math.floor(absOffset / 60));
- const offsetMinutes = this.padZeros(Math.floor(absOffset % 60));
- const offsetSign = offset > 0 ? '-' : '+';
-
- return offsetSign + offsetHours + ':' + offsetMinutes;
- },
-
- padZeros: function(num) {
- return num < 10 ? '0' + num : num.toString();
- }
-
- };
-
- return Utils;
-
-});
+const Utils = {
+
+ getISO8601Duration(milliseconds) {
+ const centiseconds = Math.round(milliseconds / 10);
+ const hours = parseInt(centiseconds / 360000, 10);
+ const minutes = parseInt((centiseconds % 360000) / 6000, 10);
+ const seconds = ((centiseconds % 360000) % 6000) / 100;
+
+ let durationString = 'PT';
+ if (hours > 0) durationString += hours + 'H';
+ if (minutes > 0) durationString += minutes + 'M';
+ durationString += seconds + 'S';
+
+ return durationString;
+ },
+
+ getTimestamp() {
+ const date = new Date();
+ const ISODate = this.getISODate(date);
+ const ISOTime = this.getISOTime(date);
+ const ISOOffset = this.getISOOffset(date);
+
+ return ISODate + 'T' + ISOTime + ISOOffset;
+ },
+
+ getISODate(date) {
+ const year = date.getFullYear();
+ const month = this.padZeros(date.getMonth() + 1);
+ const monthDay = this.padZeros(date.getDate());
+
+ return `${year}-${month}-${monthDay}`;
+ },
+
+ getISOTime(date) {
+ const hours = this.padZeros(date.getHours());
+ const minutes = this.padZeros(date.getMinutes());
+ const seconds = this.padZeros(date.getSeconds());
+ const milliseconds = this.padZeros(date.getMilliseconds());
+
+ return `${hours}:${minutes}:${seconds}.${milliseconds}`;
+ },
+
+ getISOOffset(date) {
+ const offset = date.getTimezoneOffset();
+
+ if (offset === 0) return 'Z';
+
+ const absOffset = Math.abs(offset);
+ const offsetHours = this.padZeros(Math.floor(absOffset / 60));
+ const offsetMinutes = this.padZeros(Math.floor(absOffset % 60));
+ const offsetSign = offset > 0 ? '-' : '+';
+
+ return `${offsetSign}${offsetHours}:${offsetMinutes}`;
+ },
+
+ padZeros(num) {
+ return num < 10 ? '0' + num : num.toString();
+ }
+}
+
+export default Utils;
diff --git a/properties.schema b/properties.schema
index ffecf5d..5977dca 100644
--- a/properties.schema
+++ b/properties.schema
@@ -25,6 +25,24 @@
"validators": [],
"help": "If enabled, the course will be tracked by xAPI."
},
+ "_isRestoreEnabled": {
+ "type": "boolean",
+ "required": true,
+ "default": false,
+ "title": "Restore Progress from LRS",
+ "inputType": "Checkbox",
+ "validators": [],
+ "help": "If enabled, the course will use xAPI data to restore course progress."
+ },
+ "_isDebugModeEnabled": {
+ "type": "boolean",
+ "required": true,
+ "default": false,
+ "title": "Developer Mode",
+ "inputType": "Checkbox",
+ "validators": [],
+ "help": "If enabled, the course will continue to generate statements for testing but it will instead post the statement to the console instead of the LRS."
+ },
"_activityId": {
"type": "string",
"required": true,
@@ -83,6 +101,33 @@
"inputType": "Checkbox",
"validators": [],
"help": "If enabled, the course will send a statement when the assessment have been completed. If using multiple assessments, the statement will be sent when all assessments have been completed."
+ },
+ "_navbar": {
+ "type": "boolean",
+ "required": false,
+ "default": true,
+ "title": "Record Navigational Bar Button Selections",
+ "inputType": "Checkbox",
+ "validators": [],
+ "help": "If enabled, the course will send a statement whenever a selection has been made with 'pageLevelProgress', 'drawer', 'help' or 'visua11y'."
+ },
+ "_trackingErrors": {
+ "type": "boolean",
+ "required": false,
+ "default": false,
+ "title": "Record SCORM tracking errors",
+ "inputType": "Checkbox",
+ "validators": [],
+ "help": "If enabled, the course will send a statement whenever a connection issue occurs with the spoor plug-in."
+ },
+ "_visua11y": {
+ "type": "boolean",
+ "required": false,
+ "default": false,
+ "title": "Record preference selections within visua11y",
+ "inputType": "Checkbox",
+ "validators": [],
+ "help": "If enabled, the course will send a statement whenever a preference has been selected within the visua11y options."
}
}
},
diff --git a/required/launch.html b/required/launch.html
index b30b585..a5d05bc 100644
--- a/required/launch.html
+++ b/required/launch.html
@@ -9,15 +9,15 @@
window.location = 'index.html?endpoint=' + getLRSEndpoint() + '&actor=' + getActor() + '&auth=' + getAuth() + '&activity_id=' + getActivityId() + '®istration=' + getRegistration();
function getLRSEndpoint() {
- var endpoint = "https://saas.learninglocker.net/data/xAPI/";
+ var endpoint = "https://kineo.learninglocker.net/data/xAPI";
return encodeURIComponent(endpoint);
}
function getActor() {
var actor = {
- mbox: "mailto:username@domain.com",
- name: "Test User"
+ mbox: "mailto:joe.replin@kineo.com",
+ name: "Joe Replin"
};
return JSON.stringify(actor);
@@ -25,7 +25,7 @@
function getAuth() {
// "Basic " + toBase64('username:password');
- var auth = "";
+ var auth = "Basic ZDNlNzY5MWVjMzNjYzAzYWU0MmQxNDE0MDY1NzE4ZGUxZGY4Njk3ODo0ODQyMmMyZDA4M2JkZTNjYTc1MzhlNWZkMjQ1ZGQyNGM1MWE5Y2M3";
return auth;
}
@@ -46,4 +46,4 @@
-