UNPKG

@blocdigital/usescorm

Version:
576 lines (575 loc) 21 kB
/** * 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; }; export 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); } }