jcputney/scorm-again

View on GitHub
src/BaseAPI.js

Summary

Maintainability
A
0 mins
Test Coverage
C
76%
// @flow
import {CMIArray} from './cmi/common';
import {ValidationError} from './exceptions';
import ErrorCodes from './constants/error_codes';
import APIConstants from './constants/api_constants';
import {unflatten} from './utilities';
import debounce from 'lodash.debounce';

const global_constants = APIConstants.global;
const scorm12_error_codes = ErrorCodes.scorm12;

/**
 * Base API class for AICC, SCORM 1.2, and SCORM 2004. Should be considered
 * abstract, and never initialized on it's own.
 */
export default class BaseAPI {
  #timeout;
  #error_codes;
  #settings = {
    autocommit: false,
    autocommitSeconds: 10,
    asyncCommit: false,
    sendBeaconCommit: false,
    lmsCommitUrl: false,
    dataCommitFormat: 'json', // valid formats are 'json' or 'flattened', 'params'
    commitRequestDataType: 'application/json;charset=UTF-8',
    autoProgress: false,
    logLevel: global_constants.LOG_LEVEL_ERROR,
    selfReportSessionTime: false,
    alwaysSendTotalTime: false,
    strict_errors: true,
    xhrHeaders: {},
    xhrWithCredentials: false,
    responseHandler: function(xhr) {
      let result;
      if (typeof xhr !== 'undefined') {
        result = JSON.parse(xhr.responseText);
        if (result === null || !{}.hasOwnProperty.call(result, 'result')) {
          result = {};
          if (xhr.status === 200) {
            result.result = global_constants.SCORM_TRUE;
            result.errorCode = 0;
          } else {
            result.result = global_constants.SCORM_FALSE;
            result.errorCode = 101;
          }
        }
      }
      return result;
    },
    requestHandler: function(commitObject) {
      return commitObject;
    },
    onLogMessage: function(messageLevel, logMessage) {
      switch (messageLevel) {
        case global_constants.LOG_LEVEL_ERROR:
          console.error(logMessage);
          break;
        case global_constants.LOG_LEVEL_WARNING:
          console.warn(logMessage);
          break;
        case global_constants.LOG_LEVEL_INFO:
          console.info(logMessage);
          break;
        case global_constants.LOG_LEVEL_DEBUG:
          if (console.debug) {
            console.debug(logMessage);
          } else {
            console.log(logMessage);
          }
          break;
      }
    },
  };
  cmi;
  startingData: {};

  /**
   * Constructor for Base API class. Sets some shared API fields, as well as
   * sets up options for the API.
   * @param {object} error_codes
   * @param {object} settings
   */
  constructor(error_codes, settings) {
    if (new.target === BaseAPI) {
      throw new TypeError('Cannot construct BaseAPI instances directly');
    }
    this.currentState = global_constants.STATE_NOT_INITIALIZED;
    this.lastErrorCode = 0;
    this.listenerArray = [];

    this.#timeout = null;
    this.#error_codes = error_codes;

    this.settings = settings;
    this.apiLogLevel = this.settings.logLevel;
    this.selfReportSessionTime = this.settings.selfReportSessionTime;
  }

  /**
   * Initialize the API
   * @param {string} callbackName
   * @param {string} initializeMessage
   * @param {string} terminationMessage
   * @return {string}
   */
  initialize(
      callbackName: String,
      initializeMessage?: String,
      terminationMessage?: String) {
    let returnValue = global_constants.SCORM_FALSE;

    if (this.isInitialized()) {
      this.throwSCORMError(this.#error_codes.INITIALIZED, initializeMessage);
    } else if (this.isTerminated()) {
      this.throwSCORMError(this.#error_codes.TERMINATED, terminationMessage);
    } else {
      if (this.selfReportSessionTime) {
        this.cmi.setStartTime();
      }

      this.currentState = global_constants.STATE_INITIALIZED;
      this.lastErrorCode = 0;
      returnValue = global_constants.SCORM_TRUE;
      this.processListeners(callbackName);
    }

    this.apiLog(callbackName, null, 'returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);
    this.clearSCORMError(returnValue);

    return returnValue;
  }

  /**
   * Getter for #error_codes
   * @return {object}
   */
  get error_codes() {
    return this.#error_codes;
  }

  /**
   * Getter for #settings
   * @return {object}
   */
  get settings() {
    return this.#settings;
  }

  /**
   * Setter for #settings
   * @param {object} settings
   */
  set settings(settings: Object) {
    this.#settings = {...this.#settings, ...settings};
  }

  /**
   * Terminates the current run of the API
   * @param {string} callbackName
   * @param {boolean} checkTerminated
   * @return {string}
   */
  terminate(
      callbackName: String,
      checkTerminated: boolean) {
    let returnValue = global_constants.SCORM_FALSE;

    if (this.checkState(checkTerminated,
        this.#error_codes.TERMINATION_BEFORE_INIT,
        this.#error_codes.MULTIPLE_TERMINATION)) {
      this.currentState = global_constants.STATE_TERMINATED;

      const result = this.storeData(true);
      if (!this.settings.sendBeaconCommit && !this.settings.asyncCommit &&
        typeof result.errorCode !== 'undefined' && result.errorCode > 0) {
        this.throwSCORMError(result.errorCode);
      }
      returnValue = (typeof result !== 'undefined' && result.result) ?
        result.result : global_constants.SCORM_FALSE;

      if (checkTerminated) this.lastErrorCode = 0;

      returnValue = global_constants.SCORM_TRUE;
      this.processListeners(callbackName);
    }

    this.apiLog(callbackName, null, 'returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);
    this.clearSCORMError(returnValue);

    return returnValue;
  }

  /**
   * Get the value of the CMIElement.
   *
   * @param {string} callbackName
   * @param {boolean} checkTerminated
   * @param {string} CMIElement
   * @return {string}
   */
  getValue(
      callbackName: String,
      checkTerminated: boolean,
      CMIElement: String) {
    let returnValue;

    if (this.checkState(checkTerminated,
        this.#error_codes.RETRIEVE_BEFORE_INIT,
        this.#error_codes.RETRIEVE_AFTER_TERM)) {
      if (checkTerminated) this.lastErrorCode = 0;
      try {
        returnValue = this.getCMIValue(CMIElement);
      } catch (e) {
        if (e instanceof ValidationError) {
          this.lastErrorCode = e.errorCode;
          returnValue = global_constants.SCORM_FALSE;
        } else {
          if (e.message) {
            console.error(e.message);
          } else {
            console.error(e);
          }
          this.throwSCORMError(this.#error_codes.GENERAL);
        }
      }
      this.processListeners(callbackName, CMIElement);
    }

    this.apiLog(callbackName, CMIElement, ': returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);
    this.clearSCORMError(returnValue);

    return returnValue;
  }

  /**
   * Sets the value of the CMIElement.
   *
   * @param {string} callbackName
   * @param {string} commitCallback
   * @param {boolean} checkTerminated
   * @param {string} CMIElement
   * @param {*} value
   * @return {string}
   */
  setValue(
      callbackName: String,
      commitCallback: String,
      checkTerminated: boolean,
      CMIElement,
      value) {
    if (value !== undefined) {
      value = String(value);
    }
    let returnValue = global_constants.SCORM_FALSE;

    if (this.checkState(checkTerminated, this.#error_codes.STORE_BEFORE_INIT,
        this.#error_codes.STORE_AFTER_TERM)) {
      if (checkTerminated) this.lastErrorCode = 0;
      try {
        returnValue = this.setCMIValue(CMIElement, value);
      } catch (e) {
        if (e instanceof ValidationError) {
          this.lastErrorCode = e.errorCode;
          returnValue = global_constants.SCORM_FALSE;
        } else {
          if (e.message) {
            console.error(e.message);
          } else {
            console.error(e);
          }
          this.throwSCORMError(this.#error_codes.GENERAL);
        }
      }
      this.processListeners(callbackName, CMIElement, value);
    }

    if (returnValue === undefined) {
      returnValue = global_constants.SCORM_FALSE;
    }

    // If we didn't have any errors while setting the data, go ahead and
    // schedule a commit, if autocommit is turned on
    if (String(this.lastErrorCode) === '0') {
      if (this.settings.autocommit && !this.#timeout) {
        this.scheduleCommit(this.settings.autocommitSeconds * 1000, commitCallback);
      }
    }

    this.apiLog(callbackName, CMIElement,
        ': ' + value + ': result: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);
    this.clearSCORMError(returnValue);

    return returnValue;
  }

  /**
   * Orders LMS to store all content parameters
   * @param {string} callbackName
   * @param {boolean} checkTerminated
   * @return {string}
   */
  commit(
      callbackName: String,
      checkTerminated: boolean) {
    this.clearScheduledCommit();

    let returnValue = global_constants.SCORM_FALSE;

    if (this.checkState(checkTerminated, this.#error_codes.COMMIT_BEFORE_INIT,
        this.#error_codes.COMMIT_AFTER_TERM)) {
      const result = this.storeData(false);
      if (!this.settings.sendBeaconCommit && !this.settings.asyncCommit &&
        result.errorCode && result.errorCode > 0) {
        this.throwSCORMError(result.errorCode);
      }
      returnValue = (typeof result !== 'undefined' && result.result) ?
        result.result : global_constants.SCORM_FALSE;

      this.apiLog(callbackName, 'HttpRequest', ' Result: ' + returnValue,
          global_constants.LOG_LEVEL_DEBUG);

      if (checkTerminated) this.lastErrorCode = 0;

      this.processListeners(callbackName);
    }

    this.apiLog(callbackName, null, 'returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);
    this.clearSCORMError(returnValue);

    return returnValue;
  }

  /**
   * Returns last error code
   * @param {string} callbackName
   * @return {string}
   */
  getLastError(callbackName: String) {
    const returnValue = String(this.lastErrorCode);

    this.processListeners(callbackName);

    this.apiLog(callbackName, null, 'returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);

    return returnValue;
  }

  /**
   * Returns the errorNumber error description
   *
   * @param {string} callbackName
   * @param {(string|number)} CMIErrorCode
   * @return {string}
   */
  getErrorString(callbackName: String, CMIErrorCode) {
    let returnValue = '';

    if (CMIErrorCode !== null && CMIErrorCode !== '') {
      returnValue = this.getLmsErrorMessageDetails(CMIErrorCode);
      this.processListeners(callbackName);
    }

    this.apiLog(callbackName, null, 'returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);

    return returnValue;
  }

  /**
   * Returns a comprehensive description of the errorNumber error.
   *
   * @param {string} callbackName
   * @param {(string|number)} CMIErrorCode
   * @return {string}
   */
  getDiagnostic(callbackName: String, CMIErrorCode) {
    let returnValue = '';

    if (CMIErrorCode !== null && CMIErrorCode !== '') {
      returnValue = this.getLmsErrorMessageDetails(CMIErrorCode, true);
      this.processListeners(callbackName);
    }

    this.apiLog(callbackName, null, 'returned: ' + returnValue,
        global_constants.LOG_LEVEL_INFO);

    return returnValue;
  }

  /**
   * Checks the LMS state and ensures it has been initialized.
   *
   * @param {boolean} checkTerminated
   * @param {number} beforeInitError
   * @param {number} afterTermError
   * @return {boolean}
   */
  checkState(
      checkTerminated: boolean,
      beforeInitError: number,
      afterTermError?: number) {
    if (this.isNotInitialized()) {
      this.throwSCORMError(beforeInitError);
      return false;
    } else if (checkTerminated && this.isTerminated()) {
      this.throwSCORMError(afterTermError);
      return false;
    }

    return true;
  }

  /**
   * Logging for all SCORM actions
   *
   * @param {string} functionName
   * @param {string} CMIElement
   * @param {string} logMessage
   * @param {number}messageLevel
   */
  apiLog(
      functionName: String,
      CMIElement: String,
      logMessage: String,
      messageLevel: number) {
    logMessage = this.formatMessage(functionName, CMIElement, logMessage);

    if (messageLevel >= this.apiLogLevel) {
      this.settings.onLogMessage(messageLevel, logMessage);
    }
  }

  /**
   * Formats the SCORM messages for easy reading
   *
   * @param {string} functionName
   * @param {string} CMIElement
   * @param {string} message
   * @return {string}
   */
  formatMessage(functionName: String, CMIElement: String, message: String) {
    const baseLength = 20;
    let messageString = '';

    messageString += functionName;

    let fillChars = baseLength - messageString.length;

    for (let i = 0; i < fillChars; i++) {
      messageString += ' ';
    }

    messageString += ': ';

    if (CMIElement) {
      const CMIElementBaseLength = 70;

      messageString += CMIElement;

      fillChars = CMIElementBaseLength - messageString.length;

      for (let j = 0; j < fillChars; j++) {
        messageString += ' ';
      }
    }

    if (message) {
      messageString += message;
    }

    return messageString;
  }

  /**
   * Checks to see if {str} contains {tester}
   *
   * @param {string} str String to check against
   * @param {string} tester String to check for
   * @return {boolean}
   */
  stringMatches(str: String, tester: String) {
    return str && tester && str.match(tester);
  }

  /**
   * Check to see if the specific object has the given property
   * @param {*} refObject
   * @param {string} attribute
   * @return {boolean}
   * @private
   */
  _checkObjectHasProperty(refObject, attribute: String) {
    return Object.hasOwnProperty.call(refObject, attribute) ||
      Object.getOwnPropertyDescriptor(
          Object.getPrototypeOf(refObject), attribute) ||
      (attribute in refObject);
  }

  /**
   * Returns the message that corresponds to errorNumber
   * APIs that inherit BaseAPI should override this function
   *
   * @param {(string|number)} _errorNumber
   * @param {boolean} _detail
   * @return {string}
   * @abstract
   */
  getLmsErrorMessageDetails(_errorNumber, _detail) {
    throw new Error(
        'The getLmsErrorMessageDetails method has not been implemented');
  }

  /**
   * Gets the value for the specific element.
   * APIs that inherit BaseAPI should override this function
   *
   * @param {string} _CMIElement
   * @return {string}
   * @abstract
   */
  getCMIValue(_CMIElement) {
    throw new Error('The getCMIValue method has not been implemented');
  }

  /**
   * Sets the value for the specific element.
   * APIs that inherit BaseAPI should override this function
   *
   * @param {string} _CMIElement
   * @param {any} _value
   * @return {string}
   * @abstract
   */
  setCMIValue(_CMIElement, _value) {
    throw new Error('The setCMIValue method has not been implemented');
  }

  /**
   * Shared API method to set a valid for a given element.
   *
   * @param {string} methodName
   * @param {boolean} scorm2004
   * @param {string} CMIElement
   * @param {*} value
   * @return {string}
   */
  _commonSetCMIValue(
      methodName: String, scorm2004: boolean, CMIElement, value) {
    if (!CMIElement || CMIElement === '') {
      return global_constants.SCORM_FALSE;
    }

    const structure = CMIElement.split('.');
    let refObject = this;
    let returnValue = global_constants.SCORM_FALSE;
    let foundFirstIndex = false;

    const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
    const invalidErrorCode = scorm2004 ?
      this.#error_codes.UNDEFINED_DATA_MODEL :
      this.#error_codes.GENERAL;

    for (let i = 0; i < structure.length; i++) {
      const attribute = structure[i];

      if (i === structure.length - 1) {
        if (scorm2004 && (attribute.substr(0, 8) === '{target=') &&
          (typeof refObject._isTargetValid == 'function')) {
          this.throwSCORMError(this.#error_codes.READ_ONLY_ELEMENT);
        } else if (!this._checkObjectHasProperty(refObject, attribute)) {
          this.throwSCORMError(invalidErrorCode, invalidErrorMessage);
        } else {
          if (this.isInitialized() &&
            this.stringMatches(CMIElement, '\\.correct_responses\\.\\d+')) {
            this.validateCorrectResponse(CMIElement, value);
          }

          if (!scorm2004 || this.lastErrorCode === 0) {
            refObject[attribute] = value;
            returnValue = global_constants.SCORM_TRUE;
          }
        }
      } else {
        refObject = refObject[attribute];
        if (!refObject) {
          this.throwSCORMError(invalidErrorCode, invalidErrorMessage);
          break;
        }

        if (refObject instanceof CMIArray) {
          const index = parseInt(structure[i + 1], 10);

          // SCO is trying to set an item on an array
          if (!isNaN(index)) {
            const item = refObject.childArray[index];

            if (item) {
              refObject = item;
              foundFirstIndex = true;
            } else {
              const newChild = this.getChildElement(CMIElement, value,
                  foundFirstIndex);
              foundFirstIndex = true;

              if (!newChild) {
                this.throwSCORMError(invalidErrorCode, invalidErrorMessage);
              } else {
                if (refObject.initialized) newChild.initialize();

                refObject.childArray.push(newChild);
                refObject = newChild;
              }
            }

            // Have to update i value to skip the array position
            i++;
          }
        }
      }
    }

    if (returnValue === global_constants.SCORM_FALSE) {
      this.apiLog(methodName, null,
          `There was an error setting the value for: ${CMIElement}, value of: ${value}`,
          global_constants.LOG_LEVEL_WARNING);
    }

    return returnValue;
  }

  /**
   * Abstract method for validating that a response is correct.
   *
   * @param {string} _CMIElement
   * @param {*} _value
   */
  validateCorrectResponse(_CMIElement, _value) {
    // just a stub method
  }

  /**
   * Gets or builds a new child element to add to the array.
   * APIs that inherit BaseAPI should override this method.
   *
   * @param {string} _CMIElement - unused
   * @param {*} _value - unused
   * @param {boolean} _foundFirstIndex - unused
   * @return {*}
   * @abstract
   */
  getChildElement(_CMIElement, _value, _foundFirstIndex) {
    throw new Error('The getChildElement method has not been implemented');
  }

  /**
   * Gets a value from the CMI Object
   *
   * @param {string} methodName
   * @param {boolean} scorm2004
   * @param {string} CMIElement
   * @return {*}
   */
  _commonGetCMIValue(methodName: String, scorm2004: boolean, CMIElement) {
    if (!CMIElement || CMIElement === '') {
      return '';
    }

    const structure = CMIElement.split('.');
    let refObject = this;
    let attribute = null;

    const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
    const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
    const invalidErrorCode = scorm2004 ?
      this.#error_codes.UNDEFINED_DATA_MODEL :
      this.#error_codes.GENERAL;

    for (let i = 0; i < structure.length; i++) {
      attribute = structure[i];

      if (!scorm2004) {
        if (i === structure.length - 1) {
          if (!this._checkObjectHasProperty(refObject, attribute)) {
            this.throwSCORMError(invalidErrorCode, invalidErrorMessage);
            return;
          }
        }
      } else {
        if ((String(attribute).substr(0, 8) === '{target=') &&
          (typeof refObject._isTargetValid == 'function')) {
          const target = String(attribute).substr(8, String(attribute).length - 9);
          return refObject._isTargetValid(target);
        } else if (!this._checkObjectHasProperty(refObject, attribute)) {
          this.throwSCORMError(invalidErrorCode, invalidErrorMessage);
          return;
        }
      }

      refObject = refObject[attribute];
      if (refObject === undefined) {
        this.throwSCORMError(invalidErrorCode, invalidErrorMessage);
        break;
      }

      if (refObject instanceof CMIArray) {
        const index = parseInt(structure[i + 1], 10);

        // SCO is trying to set an item on an array
        if (!isNaN(index)) {
          const item = refObject.childArray[index];

          if (item) {
            refObject = item;
          } else {
            this.throwSCORMError(this.#error_codes.VALUE_NOT_INITIALIZED,
                uninitializedErrorMessage);
            break;
          }

          // Have to update i value to skip the array position
          i++;
        }
      }
    }

    if (refObject === null || refObject === undefined) {
      if (!scorm2004) {
        if (attribute === '_children') {
          this.throwSCORMError(scorm12_error_codes.CHILDREN_ERROR);
        } else if (attribute === '_count') {
          this.throwSCORMError(scorm12_error_codes.COUNT_ERROR);
        }
      }
    } else {
      return refObject;
    }
  }

  /**
   * Returns true if the API's current state is STATE_INITIALIZED
   *
   * @return {boolean}
   */
  isInitialized() {
    return this.currentState === global_constants.STATE_INITIALIZED;
  }

  /**
   * Returns true if the API's current state is STATE_NOT_INITIALIZED
   *
   * @return {boolean}
   */
  isNotInitialized() {
    return this.currentState === global_constants.STATE_NOT_INITIALIZED;
  }

  /**
   * Returns true if the API's current state is STATE_TERMINATED
   *
   * @return {boolean}
   */
  isTerminated() {
    return this.currentState === global_constants.STATE_TERMINATED;
  }

  /**
   * Provides a mechanism for attaching to a specific SCORM event
   *
   * @param {string} listenerName
   * @param {function} callback
   */
  on(listenerName: String, callback: function) {
    if (!callback) return;

    const listenerFunctions = listenerName.split(' ');
    for (let i = 0; i < listenerFunctions.length; i++) {
      const listenerSplit = listenerFunctions[i].split('.');
      if (listenerSplit.length === 0) return;

      const functionName = listenerSplit[0];

      let CMIElement = null;
      if (listenerSplit.length > 1) {
        CMIElement = listenerName.replace(functionName + '.', '');
      }

      this.listenerArray.push({
        functionName: functionName,
        CMIElement: CMIElement,
        callback: callback,
      });

      this.apiLog('on', functionName, `Added event listener: ${this.listenerArray.length}`, global_constants.LOG_LEVEL_INFO);
    }
  }

  /**
   * Provides a mechanism for detaching a specific SCORM event listener
   *
   * @param {string} listenerName
   * @param {function} callback
   */
  off(listenerName: String, callback: function) {
    if (!callback) return;

    const listenerFunctions = listenerName.split(' ');
    for (let i = 0; i < listenerFunctions.length; i++) {
      const listenerSplit = listenerFunctions[i].split('.');
      if (listenerSplit.length === 0) return;

      const functionName = listenerSplit[0];

      let CMIElement = null;
      if (listenerSplit.length > 1) {
        CMIElement = listenerName.replace(functionName + '.', '');
      }

      const removeIndex = this.listenerArray.findIndex((obj) =>
        obj.functionName === functionName &&
        obj.CMIElement === CMIElement &&
        obj.callback === callback,
      );
      if (removeIndex !== -1) {
        this.listenerArray.splice(removeIndex, 1);
        this.apiLog('off', functionName, `Removed event listener: ${this.listenerArray.length}`, global_constants.LOG_LEVEL_INFO);
      }
    }
  }

  /**
   * Provides a mechanism for clearing all listeners from a specific SCORM event
   *
   * @param {string} listenerName
   */
  clear(listenerName: String) {
    const listenerFunctions = listenerName.split(' ');
    for (let i = 0; i < listenerFunctions.length; i++) {
      const listenerSplit = listenerFunctions[i].split('.');
      if (listenerSplit.length === 0) return;

      const functionName = listenerSplit[0];

      let CMIElement = null;
      if (listenerSplit.length > 1) {
        CMIElement = listenerName.replace(functionName + '.', '');
      }

      this.listenerArray = this.listenerArray.filter((obj) =>
        obj.functionName !== functionName &&
        obj.CMIElement !== CMIElement,
      );
    }
  }

  /**
   * Processes any 'on' listeners that have been created
   *
   * @param {string} functionName
   * @param {string} CMIElement
   * @param {*} value
   */
  processListeners(functionName: String, CMIElement: String, value: any) {
    this.apiLog(functionName, CMIElement, value);
    for (let i = 0; i < this.listenerArray.length; i++) {
      const listener = this.listenerArray[i];
      const functionsMatch = listener.functionName === functionName;
      const listenerHasCMIElement = !!listener.CMIElement;
      let CMIElementsMatch = false;
      if (CMIElement && listener.CMIElement &&
        listener.CMIElement.substring(listener.CMIElement.length - 1) ===
        '*') {
        CMIElementsMatch = CMIElement.indexOf(listener.CMIElement.substring(0,
            listener.CMIElement.length - 1)) === 0;
      } else {
        CMIElementsMatch = listener.CMIElement === CMIElement;
      }

      if (functionsMatch && (!listenerHasCMIElement || CMIElementsMatch)) {
        listener.callback(CMIElement, value);
      }
    }
  }

  /**
   * Throws a SCORM error
   *
   * @param {number} errorNumber
   * @param {string} message
   */
  throwSCORMError(errorNumber: number, message: String) {
    if (!message) {
      message = this.getLmsErrorMessageDetails(errorNumber);
    }

    this.apiLog('throwSCORMError', null, errorNumber + ': ' + message,
        global_constants.LOG_LEVEL_ERROR);

    this.lastErrorCode = String(errorNumber);
  }

  /**
   * Clears the last SCORM error code on success.
   *
   * @param {string} success
   */
  clearSCORMError(success: String) {
    if (success !== undefined && success !== global_constants.SCORM_FALSE) {
      this.lastErrorCode = 0;
    }
  }

  /**
   * Attempts to store the data to the LMS, logs data if no LMS configured
   * APIs that inherit BaseAPI should override this function
   *
   * @param {boolean} _calculateTotalTime
   * @return {string}
   * @abstract
   */
  storeData(_calculateTotalTime) {
    throw new Error(
        'The storeData method has not been implemented');
  }

  /**
   * Load the CMI from a flattened JSON object
   * @param {object} json
   * @param {string} CMIElement
   */
  loadFromFlattenedJSON(json, CMIElement) {
    if (!this.isNotInitialized()) {
      console.error(
          'loadFromFlattenedJSON can only be called before the call to lmsInitialize.');
      return;
    }

    /**
     * Test match pattern.
     *
     * @param {string} a
     * @param {string} c
     * @param {RegExp} a_pattern
     * @return {number}
     */
    function testPattern(a, c, a_pattern) {
      const a_match = a.match(a_pattern);

      let c_match;
      if (a_match !== null && (c_match = c.match(a_pattern)) !== null) {
        const a_num = Number(a_match[2]);
        const c_num = Number(c_match[2]);
        if (a_num === c_num) {
          if (a_match[3] === 'id') {
            return -1;
          } else if (a_match[3] === 'type') {
            if (c_match[3] === 'id') {
              return 1;
            } else {
              return -1;
            }
          } else {
            return 1;
          }
        }
        return a_num - c_num;
      }

      return null;
    }

    const int_pattern = /^(cmi\.interactions\.)(\d+)\.(.*)$/;
    const obj_pattern = /^(cmi\.objectives\.)(\d+)\.(.*)$/;

    const result = Object.keys(json).map(function(key) {
      return [String(key), json[key]];
    });

    // CMI interactions need to have id and type loaded before any other fields
    result.sort(function([a, b], [c, d]) {
      let test;
      if ((test = testPattern(a, c, int_pattern)) !== null) {
        return test;
      }
      if ((test = testPattern(a, c, obj_pattern)) !== null) {
        return test;
      }

      if (a < c) {
        return -1;
      }
      if (a > c) {
        return 1;
      }
      return 0;
    });

    let obj;
    result.forEach((element) => {
      obj = {};
      obj[element[0]] = element[1];
      this.loadFromJSON(unflatten(obj), CMIElement);
    });
  }

  /**
   * Loads CMI data from a JSON object.
   *
   * @param {object} json
   * @param {string} CMIElement
   */
  loadFromJSON(json, CMIElement) {
    if (!this.isNotInitialized()) {
      console.error(
          'loadFromJSON can only be called before the call to lmsInitialize.');
      return;
    }

    CMIElement = CMIElement !== undefined ? CMIElement : 'cmi';

    this.startingData = json;

    // could this be refactored down to flatten(json) then setCMIValue on each?
    for (const key in json) {
      if ({}.hasOwnProperty.call(json, key) && json[key]) {
        const currentCMIElement = (CMIElement ? CMIElement + '.' : '') + key;
        const value = json[key];

        if (value['childArray']) {
          for (let i = 0; i < value['childArray'].length; i++) {
            this.loadFromJSON(value['childArray'][i],
                currentCMIElement + '.' + i);
          }
        } else if (value.constructor === Object) {
          this.loadFromJSON(value, currentCMIElement);
        } else {
          this.setCMIValue(currentCMIElement, value);
        }
      }
    }
  }

  /**
   * Render the CMI object to JSON for sending to an LMS.
   *
   * @return {string}
   */
  renderCMIToJSONString() {
    const cmi = this.cmi;
    // Do we want/need to return fields that have no set value?
    // return JSON.stringify({ cmi }, (k, v) => v === undefined ? null : v, 2);
    return JSON.stringify({cmi});
  }

  /**
   * Returns a JS object representing the current cmi
   * @return {object}
   */
  renderCMIToJSONObject() {
    // Do we want/need to return fields that have no set value?
    // return JSON.stringify({ cmi }, (k, v) => v === undefined ? null : v, 2);
    return JSON.parse(this.renderCMIToJSONString());
  }

  /**
   * Render the cmi object to the proper format for LMS commit
   * APIs that inherit BaseAPI should override this function
   *
   * @param {boolean} _terminateCommit
   * @return {*}
   * @abstract
   */
  renderCommitCMI(_terminateCommit) {
    throw new Error(
        'The storeData method has not been implemented');
  }

  /**
   * Send the request to the LMS
   * @param {string} url
   * @param {object|Array} params
   * @param {boolean} immediate
   * @return {object}
   */
  processHttpRequest(url: String, params, immediate = false) {
    const api = this;
    const process = function(url, params, settings, error_codes) {
      const genericError = {
        'result': global_constants.SCORM_FALSE,
        'errorCode': error_codes.GENERAL,
      };

      let result;
      if (!settings.sendBeaconCommit) {
        const httpReq = new XMLHttpRequest();
        httpReq.open('POST', url, settings.asyncCommit);

        if (Object.keys(settings.xhrHeaders).length) {
          Object.keys(settings.xhrHeaders).forEach((header) => {
            httpReq.setRequestHeader(header, settings.xhrHeaders[header]);
          });
        }

        httpReq.withCredentials = settings.xhrWithCredentials;

        if (settings.asyncCommit) {
          httpReq.onload = function(e) {
            if (typeof settings.responseHandler === 'function') {
              result = settings.responseHandler(httpReq);
            } else {
              result = JSON.parse(httpReq.responseText);
            }
          };
        }
        try {
          params = settings.requestHandler(params);
          if (params instanceof Array) {
            httpReq.setRequestHeader('Content-Type',
                'application/x-www-form-urlencoded');
            httpReq.send(params.join('&'));
          } else {
            httpReq.setRequestHeader('Content-Type',
                settings.commitRequestDataType);
            httpReq.send(JSON.stringify(params));
          }

          if (!settings.asyncCommit) {
            if (typeof settings.responseHandler === 'function') {
              result = settings.responseHandler(httpReq);
            } else {
              result = JSON.parse(httpReq.responseText);
            }
          } else {
            result = {};
            result.result = global_constants.SCORM_TRUE;
            result.errorCode = 0;
            api.processListeners('CommitSuccess');
            return result;
          }
        } catch (e) {
          console.error(e);
          api.processListeners('CommitError');
          return genericError;
        }
      } else {
        try {
          params = settings.requestHandler(params);
          fetch(url, {
            method: 'POST',
            body: params instanceof Array ? params.join('&') : JSON.stringify(params),
            headers: {
              ...settings.xhrHeaders,
              'Content-Type': settings.commitRequestDataType,
            },
            credentials: settings.xhrWithCredentials ? 'include' : undefined,
            keepalive: true,
          });
          result = {};
          result.result = global_constants.SCORM_TRUE;
          result.errorCode = 0;
        } catch (e) {
          console.error(e);
          api.processListeners('CommitError');
          return genericError;
        }
      }

      if (typeof result === 'undefined') {
        api.processListeners('CommitError');
        return genericError;
      }

      if (result.result === true ||
        result.result === global_constants.SCORM_TRUE) {
        api.processListeners('CommitSuccess');
      } else {
        api.processListeners('CommitError');
      }

      return result;
    };

    if (typeof debounce !== 'undefined') {
      const debounced = debounce(process, 500);
      debounced(url, params, this.settings, this.error_codes);

      // if we're terminating, go ahead and commit immediately
      if (immediate) {
        debounced.flush();
      }

      return {
        result: global_constants.SCORM_TRUE,
        errorCode: 0,
      };
    } else {
      return process(url, params, this.settings, this.error_codes);
    }
  }

  /**
   * Throws a SCORM error
   *
   * @param {number} when - the number of milliseconds to wait before committing
   * @param {string} callback - the name of the commit event callback
   */
  scheduleCommit(when: number, callback: string) {
    this.#timeout = new ScheduledCommit(this, when, callback);
    this.apiLog('scheduleCommit', '', 'scheduled',
        global_constants.LOG_LEVEL_DEBUG);
  }

  /**
   * Clears and cancels any currently scheduled commits
   */
  clearScheduledCommit() {
    if (this.#timeout) {
      this.#timeout.cancel();
      this.#timeout = null;
      this.apiLog('clearScheduledCommit', '', 'cleared',
          global_constants.LOG_LEVEL_DEBUG);
    }
  }
}

/**
 * Private class that wraps a timeout call to the commit() function
 */
class ScheduledCommit {
  #API;
  #cancelled = false;
  #timeout;
  #callback;

  /**
   * Constructor for ScheduledCommit
   * @param {BaseAPI} API
   * @param {number} when
   * @param {string} callback
   */
  constructor(API: any, when: number, callback: string) {
    this.#API = API;
    this.#timeout = setTimeout(this.wrapper.bind(this), when);
    this.#callback = callback;
  }

  /**
   * Cancel any currently scheduled commit
   */
  cancel() {
    this.#cancelled = true;
    if (this.#timeout) {
      clearTimeout(this.#timeout);
    }
  }

  /**
   * Wrap the API commit call to check if the call has already been cancelled
   */
  wrapper() {
    if (!this.#cancelled) {
      this.#API.commit(this.#callback);
    }
  }
}