@blocdigital/usescorm
Version:
React hook for communicating with the SCORM API.
580 lines (579 loc) • 21.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScormInstance = void 0;
/**
* Convert data type to boolean
* @param {unknown} value any data
* @returns {boolean} converted value
*/
const parseBool = (value) => {
switch (typeof value) {
case 'object':
return true;
case 'string':
return /(true|1)/i.test(value);
case 'number':
return !!value;
case 'boolean':
return value;
default:
return false;
}
};
/**
* Calculate the size of a string in bytes
*
* @param str string to be used
* @returns size in bytes
*/
const calculateStringSizeInBytes = (str) => {
// Use the TextEncoder API to encode the string as UTF-8
const encoder = new TextEncoder();
const encodedString = encoder.encode(str);
// The length of the encoded string represents the size in bytes
return encodedString.length;
};
class ScormInstance {
_version;
_handleCompletionStatus;
_handleExitMode;
_autoCommit;
onList = [];
onAnyList = [];
get version() {
return this._version;
}
get handleCompletionStatus() {
return this._handleCompletionStatus;
}
get handleExitMode() {
return this._handleExitMode;
}
get autoCommit() {
return this._autoCommit;
}
get storage() {
const _storage = this.get('cmi.suspend_data');
return _storage ? JSON.parse(_storage) : null;
}
API = {
handle: null,
isFound: false,
find: this.scormApiFind.bind(this),
get: this.scormApiGet.bind(this),
getHandle: this.scormApiGetHandle.bind(this),
};
connection = { isActive: false };
data = {
completionStatus: 'not attempted',
exitStatus: false,
};
debug = {
isActive: false,
getCode: this.scormDebugGetCode.bind(this),
getInfo: this.scormDebugGetInfo.bind(this),
getDiagnosticInfo: this.scormDebugGetDiagnosticInfo.bind(this),
};
constructor({ version, debug, handleCompletionStatus = true, handleExitMode = true, autoCommit = true, }) {
// Set up params
this._version = version;
this.debug.isActive = Boolean(debug);
this._handleCompletionStatus = handleCompletionStatus;
this._handleExitMode = Boolean(handleExitMode);
this._handleExitMode = Boolean(handleExitMode);
this._autoCommit = Boolean(autoCommit);
}
/**
* only console.log if in debug mode
*
* @param message info to log
*/
log(...message) {
if (this.debug.isActive)
console.log(...message);
}
//----------------------------//
// SCORM Connection //
//----------------------------//
init() {
const { debug, data, connection } = this;
const traceMsgPrefix = 'SCORM.connection.initialize ';
this.log('connection.initialize called.');
// if the connection is already active don't init
if (connection.isActive) {
this.log(`${traceMsgPrefix}aborted: Connection already active.`);
return false;
}
if (!this.connection.isActive) {
const API = this.API.getHandle();
let errorCode = 0;
let success = false;
if (API) {
switch (this._version) {
case '1.2':
success = parseBool(API.LMSInitialize(''));
break;
case '2004':
success = parseBool(API.Initialize(''));
break;
}
if (success) {
//Double-check that connection is active and working before returning 'true' boolean
errorCode = debug.getCode();
if (errorCode !== null && errorCode === 0) {
this.connection.isActive = true;
this.handleEvent('init', true);
if (this._handleCompletionStatus) {
//Automatically set new launches to incomplete
data.completionStatus = this.status('get');
if (data.completionStatus) {
switch (data.completionStatus) {
//Both SCORM 1.2 and 2004
case 'not attempted':
this.status('set', 'incomplete');
break;
//SCORM 2004 only
case 'unknown':
this.status('set', 'incomplete');
break;
}
//Commit changes
this.save();
}
}
}
else {
success = false;
this.log(`${traceMsgPrefix}failed.
Error code: ${errorCode}
Error info: ${debug.getInfo(errorCode)}`);
}
}
else {
errorCode = debug.getCode();
if (errorCode !== null && errorCode !== 0) {
this.log(`${traceMsgPrefix}failed.
Error code: ${errorCode}
Error info: ${debug.getInfo(errorCode)}`);
}
else {
this.log(`${traceMsgPrefix}failed: No response from server.`);
}
}
}
else {
this.log(`${traceMsgPrefix}failed: API is null.`);
}
return success;
}
}
terminate() {
const { debug, connection, handleExitMode, version, data } = this;
const { completionStatus, exitStatus } = data;
const traceMsgPrefix = 'SCORM.connection.terminate ';
let success = false;
if (connection.isActive) {
const API = this.API.getHandle();
let errorCode = 0;
if (API) {
if (handleExitMode && !exitStatus) {
if (completionStatus !== 'completed' && completionStatus !== 'passed') {
switch (version) {
case '1.2':
success = this.set('cmi.core.exit', 'suspend');
break;
case '2004':
success = this.set('cmi.exit', 'suspend');
break;
}
}
else {
switch (version) {
case '1.2':
success = this.set('cmi.core.exit', 'logout');
break;
case '2004':
success = this.set('cmi.exit', 'normal');
break;
}
}
if (this.autoCommit)
this.save();
}
//Ensure we persist the data for 1.2 - not required for 2004 where an implicit commit is applied during the Terminate
success = this.version === '1.2' ? this.save() : true;
if (success) {
switch (this.version) {
case '1.2':
success = parseBool(API.LMSFinish(''));
break;
case '2004':
success = parseBool(API.Terminate(''));
break;
}
if (success) {
connection.isActive = false;
}
else {
errorCode = debug.getCode();
this.log(`${traceMsgPrefix}failed.
Error code: ${errorCode}
Error info: ${debug.getInfo(errorCode)}`);
}
}
}
else {
this.log(`${traceMsgPrefix}failed: API is null.`);
}
}
else {
this.log(`${traceMsgPrefix}aborted: Connection already terminated.`);
}
return success;
}
//----------------------------//
// SCORM Data //
//----------------------------//
get(parameter) {
const { debug, connection, version, data } = this;
const traceMsgPrefix = "SCORM.data.get('" + parameter + "') ";
let value = null;
if (connection.isActive) {
const API = this.API.getHandle();
let errorCode = 0;
if (API) {
switch (version) {
case '1.2':
value = API.LMSGetValue(parameter);
break;
case '2004':
value = API.GetValue(parameter);
break;
}
errorCode = debug.getCode();
//GetValue returns an empty string on errors
//If value is an empty string, check errorCode to make sure there are no errors
if (value !== '' || errorCode === 0) {
//GetValue is successful.
//If parameter is lesson_status/completion_status or exit status, let's
//grab the value and cache it so we can check it during connection.terminate()
switch (parameter) {
case 'cmi.core.lesson_status':
case 'cmi.completion_status':
data.completionStatus = value;
break;
case 'cmi.core.exit':
case 'cmi.exit':
data.exitStatus = Boolean(value);
break;
}
}
else {
this.log(`${traceMsgPrefix}failed.
Error code: ${errorCode}
Error info: ${debug.getInfo(errorCode)}`);
}
}
else {
this.log(`${traceMsgPrefix}failed: API is null.`);
}
}
else {
this.log(`${traceMsgPrefix}failed: API connection is inactive.`);
}
this.log(`${traceMsgPrefix} value: ${value}`);
return String(value);
}
set(parameter, value) {
const { debug, connection, version, data } = this;
const traceMsgPrefix = "SCORM.data.set('" + parameter + "') ";
let success = false;
if (connection.isActive) {
const API = this.API.getHandle();
let errorCode = 0;
if (API) {
switch (version) {
case '1.2':
success = parseBool(API.LMSSetValue(parameter, value));
break;
case '2004':
success = parseBool(API.SetValue(parameter, value));
break;
}
if (success) {
if (parameter === 'cmi.core.lesson_status' || parameter === 'cmi.completion_status') {
data.completionStatus = value;
}
this.handleEvent('set', { parameter, value });
if (this._autoCommit)
this.save();
}
else {
errorCode = debug.getCode();
this.log(`${traceMsgPrefix}failed.
Error code: ${errorCode}
Error info: ${debug.getInfo(errorCode)}`);
}
}
else {
this.log(`${traceMsgPrefix}failed: API is null.`);
}
}
else {
this.log(`${traceMsgPrefix}failed: API connection is inactive.`);
}
this.log(`${traceMsgPrefix} value: ${value}`);
return success;
}
store(value) {
const { storage } = this;
const _value = typeof value === 'function' ? value(storage) : value;
const string = JSON.stringify(_value);
// if there is nothing to update give up
if (string === JSON.stringify(storage))
return false;
const size = calculateStringSizeInBytes(string);
const limit = this.version === '1.2' ? 4096 : 64000;
if (size >= limit) {
console.warn(`SCORM ${this.version} has suspend data limit of ${limit}B,\nyour data is currently at ${size}B and may not be stored correctly`);
}
const success = this.set('cmi.suspend_data', string);
if (!success)
return false;
this.handleEvent('storage', _value);
return this.autoCommit ? this.save() : success;
}
save() {
const { connection, version } = this;
const traceMsgPrefix = 'SCORM.data.save failed';
let success = false;
if (connection.isActive) {
const API = this.API.getHandle();
if (API) {
switch (version) {
case '1.2':
success = parseBool(API.LMSCommit(''));
break;
case '2004':
success = parseBool(API.Commit(''));
break;
}
}
else {
this.log(traceMsgPrefix + ': API is null.');
}
}
else {
this.log(traceMsgPrefix + ': API connection is inactive.');
}
return success;
}
//----------------------------//
// SCORM Status //
//----------------------------//
status(action, status) {
const { version } = this;
const traceMsgPrefix = 'SCORM.getStatus failed';
const cmi = (version === '1.2' ? 'cmi.core.lesson_status' : 'cmi.completion_status');
if (!action) {
this.log(traceMsgPrefix + ': action was not specified.');
return false;
}
switch (action) {
case 'get':
return this.get(cmi);
case 'set':
if (!status) {
this.log(traceMsgPrefix + ': status was not specified.');
return false;
}
if (!this.autoCommit)
return this.set(cmi, status);
this.set(cmi, status);
return this.save();
default:
this.log(traceMsgPrefix + ': no valid action was specified.');
return false;
}
}
//---------------------------//
// SCORM API //
//---------------------------//
/**
* Find a copy of SCORM
*
* @param win the window
*/
scormApiFind(win) {
const findAttemptLimit = 500;
const traceMsgPrefix = 'SCORM.API.find';
let API = null;
let findAttempts = 0;
try {
while (!win.API && !win.API_1484_11 && win.parent && win.parent != win && findAttempts <= findAttemptLimit) {
findAttempts++;
win = win.parent;
}
//If SCORM version is specified by user, look for specific API
if (this.version) {
switch (this.version) {
case '2004':
if (!win.API_1484_11)
throw new Error(`${traceMsgPrefix}: SCORM version 2004 was specified by user, but API_1484_11 cannot be found.`);
API = win.API_1484_11;
break;
case '1.2':
if (!win.API)
throw new Error(`${traceMsgPrefix}: SCORM version 1.2 was specified by user, but API cannot be found.`);
API = win.API;
break;
}
}
else {
//If SCORM version not specified by user, look for APIs
if (win.API_1484_11) {
//SCORM 2004-specific API.
this._version = '2004'; //Set version
API = win.API_1484_11;
}
else if (win.API) {
//SCORM 1.2-specific API
this._version = '1.2'; //Set version
API = win.API;
}
}
if (!API)
throw new Error(`${traceMsgPrefix}: Error finding API.
Find attempts: ${findAttempts}
Find attempt limit: ${findAttemptLimit}`);
this.log(`${traceMsgPrefix}: API found. Version: ${this.version}`);
this.log('API:', API);
return API;
}
catch (err) {
this.log(err);
return null;
}
}
/**
* Find a copy of SCORM (using current window)
*/
scormApiGet() {
const win = window;
try {
const API = this.scormApiFind(win) ||
(win.parent && win.parent != win && this.scormApiFind(win.parent)) ||
(win.top?.opener && this.scormApiFind(win.top.opener)) ||
(win.top?.opener.document && this.scormApiFind(win.top.opener.document));
if (!API)
throw new Error("API.get failed: Can't find the API!");
this.API.isFound = true;
return API;
}
catch (err) {
this.log(err);
return null;
}
}
/**
* Find a copy of SCORM (using current window + update handle)
*/
scormApiGetHandle() {
const { API } = this;
if (!API.handle && !API.isFound)
API.handle = this.scormApiGet();
return API.handle;
}
//----------------------------//
// SCORM debug //
//----------------------------//
/**
* Returns the error code that resulted from the last API call
*
* @returns ErrorCode
*/
scormDebugGetCode() {
const API = this.API.getHandle();
// if there is no API give up
if (!API) {
this.log('SCORM.debug.getCode failed: API is null.');
return;
}
switch (this.version) {
case '1.2':
return parseInt(API.LMSGetLastError(), 10);
case '2004':
return parseInt(API.GetLastError(), 10);
}
}
/**
* Returns a short string describing the specified error code
*
* @param {number} errorCode code to lookup
* @returns error description
*/
scormDebugGetInfo(errorCode) {
const API = this.API.getHandle();
// if there is no API give up
if (!API) {
this.log('SCORM.debug.getInfo failed: API is null.');
return;
}
switch (this.version) {
case '1.2':
return API.LMSGetErrorString(errorCode.toString());
case '2004':
return API.GetErrorString(errorCode.toString());
}
}
/**
* Returns detailed information about the last error that occurred.
*
* @param errorCode code to lookup
* @returns Diagnosis
*/
scormDebugGetDiagnosticInfo(errorCode) {
const API = this.API.getHandle();
// if there is no API give up
if (!API) {
this.log('SCORM.debug.getDiagnosticInfo failed: API is null.');
return;
}
switch (this.version) {
case '1.2':
return API.LMSGetDiagnostic(errorCode.toString());
case '2004':
return API.GetDiagnostic(errorCode.toString());
}
}
//----------------------------//
// Storage Events //
//----------------------------//
/**
* Listen for an event and trigger a callback
*
* @param event event type
* @param data data that was modified
*/
handleEvent(event, data) {
this.onList.filter((obj) => obj.type === event).forEach((obj) => obj.callback(data));
this.onAnyList.forEach((obj) => obj.callback(event, data));
}
on(event, callback) {
this.onList.push({ type: event, callback });
}
onAny(callback) {
this.onAnyList.push({ callback });
}
off(event, callback) {
const remove = this.onList.indexOf(this.onList.filter((e) => e.type === event && e.callback === callback)[0]);
if (remove >= 0)
this.onList.splice(remove, 1);
}
offAny(callback) {
const remove = this.onAnyList.indexOf(this.onAnyList.filter((e) => e.callback === callback)[0]);
if (remove >= 0)
this.onAnyList.splice(remove, 1);
}
}
exports.ScormInstance = ScormInstance;