;(function(root) {
// uncomment for some console.log debugging
// root.TinCan.DEBUG = true;
var tincan;
var statementQueue = [];
var connected = false;
var getUUID = root.TinCan.Utils.getUUID;
var formatTime = root.TinCan.Utils.convertMillisecondsToISO8601Duration;
var noop = Function.prototype;
var state = {
completed: false,
completionFn: noop,
prevDuration: null,
score: null,
startTime: null,
suspend: '',
loaded: {
bookmark: false,
duration: false,
suspend: false,
}
}
var inFlight = false;
var isDraining = false;
// Needed to account for various LRS providers,
// when draining queued statements
var DRAIN_DELAY = 600; // (ms)
var BOOKMARK = 'bookmark';
var SUSPEND_DATA = 'suspend_data';
var TOTAL_TIME = 'cumulative_time';
var COURSE = 'http://adlnet.gov/expapi/activities/course';
var INTERACTION = 'http://adlnet.gov/expapi/activities/cmi.interaction';
var LESSON = 'http://adlnet.gov/expapi/activities/module';
var PROGRESSED = 'http://adlnet.gov/expapi/verbs/progressed';
function isFile() {
return /file/.test(root.location.protocol)
}
function assign(target) {
if (target === undefined || target === null) {
throw new TypeError('assign: Cannot convert undefined or null to object');
}
var output = Object(target);
var source;
var nextKey;
for (var index = 1; index < arguments.length; index++) {
source = arguments[index];
if (source !== undefined && source !== null) {
for (nextKey in source) {
if (source.hasOwnProperty(nextKey)) {
output[nextKey] = source[nextKey];
}
}
}
}
return output;
}
function debounce(fn, delay) {
var timer = null;
return function () {
var context = this, args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
}
// For unloading we need to hold the main thread
// but still send async statements to LRS in a
// spaced out manner. (600ms is recommended by Rustici)
function holdThread() {
var start = Date.now();
var now = start;
while(now - start < DRAIN_DELAY) {
now = Date.now();
}
}
function closeContent() {
if(root.top === root) {
root.close();
}
else {
root.location.pathname =
root.location.pathname
.split('/')
.slice(0, -1)
.join('/')
.concat('/goodbye.html');
}
}
function queueStatement(stmt) {
if(inFlight) {
statementQueue.push(stmt);
} else {
sendStatement(stmt);
}
}
function popStatement() {
if(statementQueue.length && !isDraining) {
sendStatement(statementQueue.shift());
} else {
inFlight = false;
}
}
function drainQueue() {
isDraining = true;
if(!statementQueue.length) {
return null;
}
statementQueue.forEach(sendStatementDelayed);
return null;
}
function sendStatement(attribs) {
if(connected) {
inFlight = true;
tincan.sendStatement(createStatement(attribs), popStatement);
}
}
function sendStatementDelayed(attribs) {
sendStatement(attribs);
holdThread();
}
function createStatement(stmt) {
var definition = assign({}, {
type: stmt.type || COURSE
}, stmt.definition || {});
var objectId = ([ tincan.activity.id ])
.concat(stmt.id || [])
.join('/');
if(stmt.name) {
definition.name = { und: stmt.name };
}
if(stmt.description) {
definition.description = { und: stmt.description };
}
return {
id: getUUID(),
object: { id: objectId, definition: definition },
objectType: 'Activity',
result: stmt.result,
verb: stmt.verb
};
}
function createVerb(verbName) {
var verb = new TinCan.Verb(verbName);
if (verb.display.und) {
verb.display['en-US'] = verb.display.und;
delete verb.display.und;
}
return verb;
}
function sendAttempted() {
commitFinishData({
verb: createVerb('attempted')
});
}
function sendExperienced(lessonId) {
commitData({
id: lessonId,
type: LESSON,
verb: createVerb('experienced')
});
}
function sendProgressed(courseProgress) {
commitFinishData({
verb: createVerb({
id: PROGRESSED,
display: { 'en-US': 'progressed' }
}),
result: {
extensions: {
'https://w3id.org/xapi/cmi5/result/extensions/progress': courseProgress
}}
});
}
function addFinishData(stmt) {
const result = assign({}, stmt.result);
result.duration = formatTime(getDuration());
result.completion = state.completed;
if(state.score !== null) {
result.score = state.score;
}
return assign({}, stmt, { result: result });
}
function getDuration() {
state.prevDuration = state.prevDuration + ((new Date()).getTime() - state.startTime)
state.startTime = (new Date()).getTime();
return state.prevDuration;
}
function commitFinishData(stmt) {
commitData(addFinishData(stmt));
}
function commitData(stmt) {
queueStatement(stmt);
}
function getState(key, cb) {
if(connected) {
if(typeof cb === 'function') {
tincan.getState(key, {
callback: function(err, result) {
if(!err) {
cb((result && result.contents) || '');
return;
}
cb('');
}
});
return;
}
try {
var state =
tincan.getState(key, cfg || {}).state;
return state && state.contents
? state.contents
: '';
}
catch(ex) {
return '';
}
}
// if not connected, call callback with blank state
if(typeof cb === 'function') {
cb('');
}
}
function setState(key, data, cfg) {
if(connected) {
try {
tincan.setState(key, data, cfg);
}
catch(ex) {
return;
}
}
}
function FinishLesson(courseProgress, lessonId) {
sendExperienced(lessonId);
sendProgressed(courseProgress);
}
function SetBookmark(data) {
setState(BOOKMARK, data, { callback: noop });
}
function SetDataChunk(data) {
setState(SUSPEND_DATA, data, { callback: noop });
setState(TOTAL_TIME, getDuration(), { callback: noop });
}
function GetDataChunk() {
return state.suspend;
}
function SetScore(newScore, max, min) {
state.score = {
raw: newScore,
max: max,
min: min
};
}
function InitCompletion(completedFn) {
state.completedFn = completedFn;
}
function SetFailed() {
state.completed = true;
commitFinishData({
result: { success: false },
verb: createVerb('failed')
});
}
function SetPassed() {
state.completed = true;
commitFinishData({
result: { success: true },
verb: createVerb('passed')
});
}
// Record Answer Data
function buildDescriptionObj(id, title) {
return {
id: id,
description: {
und: title
}
}
}
function prop(property) {
return function (obj) {
return obj[property];
}
}
function mapMatchingArray(match) {
return match.source.id + '[.]' + match.target.id;
}
function joinArray(a) {
return a.join('[,]');
}
function getSources(answers) {
var mapAnswers = function(answer) {
return buildDescriptionObj(answer.source.id, answer.source.title);
};
return answers.map(mapAnswers);
}
function getTargets(answers) {
var mapAnswers = function(answer) {
return buildDescriptionObj(answer.target.id, answer.target.title);
};
return answers.map(mapAnswers);
}
function getChoices(answers) {
var mapAnswers = function(answer) {
return buildDescriptionObj(answer.id, answer.title);
};
return answers.map(mapAnswers);
}
function getCorrectResponsesPattern(data) {
var correctResponse = data.correctResponse;
switch (data.type) {
case 'FILL_IN_THE_BLANK':
return correctResponse;
case 'MATCHING':
return [ joinArray(correctResponse.map(mapMatchingArray)) ];
case 'MULTIPLE_CHOICE':
case 'MULTIPLE_RESPONSE':
default:
return [ joinArray(correctResponse.map(prop('id'))) ];
}
}
function buildDefinition(data) {
var answers = data.answers;
var correctResponse = data.correctResponse;
var type = data.type;
var interactionTypes = {
FILL_IN_THE_BLANK: 'fill-in',
MATCHING: 'matching',
MULTIPLE_CHOICE: 'choice',
MULTIPLE_RESPONSE: 'choice'
};
var definition = {
interactionType: interactionTypes[type || 'MULTIPLE_CHOICE'],
correctResponsesPattern: getCorrectResponsesPattern(data)
};
if (type === 'MULTIPLE_CHOICE' || type === 'MULTIPLE_RESPONSE') {
definition.choices = getChoices(answers);
}
if (type === 'MATCHING') {
definition.source = getSources(answers);
definition.target = getTargets(answers);
}
return definition;
}
function buildResponse(data) {
var response = data.response;
switch (data.type) {
case 'FILL_IN_THE_BLANK':
return response;
case 'MATCHING':
return joinArray(response.map(mapMatchingArray));
case 'MULTIPLE_CHOICE':
case 'MULTIPLE_RESPONSE':
default:
return joinArray(response.map(prop('id')));
}
}
function ReportAnswer(data) {
var definition = buildDefinition(data);
var response = buildResponse(data);
var title = data.questionTitle;
commitData({
definition: definition,
description: title,
id: data.id + '/' + data.itemId + '_' + Date.now(),
name: title,
result: {
success: data.isCorrect,
response: response
},
type: INTERACTION,
verb: createVerb('answered')
});
}
function ConcedeControl() {
drainQueue();
root.removeEventListener('beforeunload', ConcedeControl);
closeContent();
}
function checkLoaded() {
var loaded = state.loaded;
if(loaded.bookmark && loaded.suspend && loaded.duration) {
loadBundle();
}
}
function initialize() {
state.startTime =
(new Date()).getTime();
loadBookmark();
loadSuspend();
loadDuration();
}
function loadBookmark() {
getState(BOOKMARK, function(hash) {
if(hash) {
if(root.history.pushState) {
root.history.pushState(null, null, hash);
} else {
root.location.hash = hash;
}
}
state.loaded.bookmark = true;
checkLoaded();
});
}
function loadDuration() {
getState(TOTAL_TIME, function(duration) {
state.prevDuration = Number(duration || 0);
state.loaded.duration = true;
checkLoaded();
});
}
function loadBundle() {
__loadEntry()
sendAttempted();
}
function loadSuspend() {
getState(SUSPEND_DATA, function(data) {
if(data) {
state.suspend = data;
state.completed = state.completedFn();
}
state.loaded.suspend = true;
checkLoaded();
});
}
function lms() {
root.removeEventListener('beforeunload', ConcedeControl);
root.addEventListener('beforeunload', ConcedeControl);
var url =
root.location.href.replace(root.location.hash, '');
var config = {
activity: {
id: TC_COURSE_ID,
definition: {
name: TC_COURSE_NAME,
description: TC_COURSE_DESC
}
}
}
tincan = new TinCan(isFile()
? config
: assign({}, config, { url: url })
);
connected = (tincan.recordStores || []).length;
initialize();
return {
SetBookmark: SetBookmark,
SetDataChunk: SetDataChunk,
GetDataChunk: GetDataChunk,
FinishLesson: FinishLesson,
InitCompletion: InitCompletion,
ReportAnswer: ReportAnswer,
SetFailed: SetFailed,
SetPassed: SetPassed,
SetScore: SetScore,
ConcedeControl: ConcedeControl,
utils: {
assign: assign,
debounce: debounce
}
};
}
root.lms = lms;
}(window));