scorm-again
Version:
A modern SCORM JavaScript run-time library for AICC, SCORM 1.2, and SCORM 2004
1,336 lines (1,175 loc) • 37.4 kB
text/typescript
import { CMIArray } from "./cmi/common/array";
import { ValidationError } from "./exceptions";
import { ErrorCode } from "./constants/error_codes";
import { global_constants } from "./constants/api_constants";
import { formatMessage, stringMatches, unflatten } from "./utilities";
import { BaseCMI } from "./cmi/common/base_cmi";
import {
CommitObject,
InternalSettings,
LogLevel,
RefObject,
ResultObject,
Settings
} from "./types/api_types";
import { DefaultSettings } from "./constants/default_settings";
import { IBaseAPI } from "./interfaces/IBaseAPI";
import { ScheduledCommit } from "./helpers/scheduled_commit";
import { LogLevelEnum } from "./constants/enums";
/**
* Base API class for AICC, SCORM 1.2, and SCORM 2004. Should be considered
* abstract, and never initialized on its own.
*/
export default abstract class BaseAPI implements IBaseAPI {
private _timeout?: ScheduledCommit | undefined;
private readonly _error_codes: ErrorCode;
private _settings: InternalSettings = DefaultSettings;
/**
* Constructor for Base API class. Sets some shared API fields, as well as
* sets up options for the API.
* @param {ErrorCode} error_codes
* @param {Settings} settings
*/
protected constructor(error_codes: ErrorCode, settings?: 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._error_codes = error_codes;
if (settings) {
this.settings = {
...DefaultSettings,
...settings
} as InternalSettings;
}
this.apiLogLevel = this.settings.logLevel ?? LogLevelEnum.ERROR;
this.selfReportSessionTime = this.settings.selfReportSessionTime ?? false;
}
public abstract cmi: BaseCMI;
public startingData?: RefObject;
public currentState: number;
public lastErrorCode: string;
public listenerArray: any[];
public apiLogLevel: LogLevel;
public selfReportSessionTime: boolean;
abstract reset(settings?: Settings): void;
/**
* Common reset method for all APIs. New settings are merged with the existing settings.
* @param {Settings} settings
* @protected
*/
commonReset(settings?: Settings): void {
this.apiLog("reset", "Called", LogLevelEnum.INFO);
this.settings = { ...this.settings, ...settings };
this.clearScheduledCommit();
this.currentState = global_constants.STATE_NOT_INITIALIZED;
this.lastErrorCode = "0";
this.listenerArray = [];
this.startingData = {};
}
/**
* Initialize the API
* @param {string} callbackName
* @param {string} initializeMessage
* @param {string} terminationMessage
* @return {string}
*/
initialize(
callbackName: string,
initializeMessage?: string,
terminationMessage?: string
): string {
let returnValue = global_constants.SCORM_FALSE;
if (this.isInitialized()) {
this.throwSCORMError(
this._error_codes.INITIALIZED as number,
initializeMessage
);
} else if (this.isTerminated()) {
this.throwSCORMError(
this._error_codes.TERMINATED as number,
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, "returned: " + returnValue, LogLevelEnum.INFO);
this.clearSCORMError(returnValue);
return returnValue;
}
abstract lmsInitialize(): string;
abstract lmsFinish(): string;
abstract lmsGetValue(CMIElement: string): string;
abstract lmsSetValue(CMIElement: string, value: any): string;
abstract lmsCommit(): string;
abstract lmsGetLastError(): string;
abstract lmsGetErrorString(CMIErrorCode: string | number): string;
abstract lmsGetDiagnostic(CMIErrorCode: string | number): string;
/**
* Abstract method for validating that a response is correct.
*
* @param {string} _CMIElement
* @param {any} _value
*/
abstract validateCorrectResponse(_CMIElement: string, _value: any): void;
/**
* 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 {BaseCMI|null}
* @abstract
*/
abstract getChildElement(
_CMIElement: string,
_value: any,
_foundFirstIndex: boolean
): BaseCMI | null;
/**
* 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 {ResultObject}
* @abstract
*/
abstract storeData(_calculateTotalTime: boolean): Promise<ResultObject>;
/**
* Render the cmi object to the proper format for LMS commit
* APIs that inherit BaseAPI should override this function
*
* @param {boolean} _terminateCommit
* @return {RefObject|Array}
* @abstract
*/
abstract renderCommitCMI(_terminateCommit: boolean): RefObject | Array<any>;
/**
* Render the commit object to the shortened format for LMS commit
* @param {boolean} _terminateCommit
* @return {CommitObject}
*/
abstract renderCommitObject(_terminateCommit: boolean): CommitObject;
/**
* Logging for all SCORM actions
*
* @param {string} functionName
* @param {string} logMessage
* @param {number} messageLevel
* @param {string} CMIElement
*/
apiLog(
functionName: string,
logMessage: string,
messageLevel: LogLevel,
CMIElement?: string
) {
logMessage = formatMessage(functionName, logMessage, CMIElement);
if (messageLevel >= this.apiLogLevel) {
this.settings.onLogMessage(messageLevel, logMessage);
}
}
/**
* Getter for _error_codes
* @return {ErrorCode}
*/
get error_codes(): ErrorCode {
return this._error_codes;
}
/**
* Getter for _settings
* @return {Settings}
*/
get settings(): InternalSettings {
return this._settings;
}
/**
* Setter for _settings
* @param {Settings} settings
*/
set settings(settings: Settings) {
this._settings = { ...this._settings, ...settings };
}
/**
* Terminates the current run of the API
* @param {string} callbackName
* @param {boolean} checkTerminated
* @return {string}
*/
async terminate(
callbackName: string,
checkTerminated: boolean
): Promise<string> {
let returnValue = global_constants.SCORM_FALSE;
if (
this.checkState(
checkTerminated,
this._error_codes.TERMINATION_BEFORE_INIT as number,
this._error_codes.MULTIPLE_TERMINATION as number
)
) {
this.currentState = global_constants.STATE_TERMINATED;
const result: ResultObject = await this.storeData(true);
if (typeof result.errorCode !== "undefined" && result.errorCode > 0) {
this.throwSCORMError(result.errorCode);
}
returnValue =
typeof result !== "undefined" && (result.result === true || result.result === global_constants.SCORM_TRUE)
? global_constants.SCORM_TRUE
: global_constants.SCORM_FALSE;
if (checkTerminated) this.lastErrorCode = "0";
returnValue = global_constants.SCORM_TRUE;
this.processListeners(callbackName);
}
this.apiLog(callbackName, "returned: " + returnValue, LogLevelEnum.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
): string {
let returnValue: string = "";
if (
this.checkState(
checkTerminated,
this._error_codes.RETRIEVE_BEFORE_INIT as number,
this._error_codes.RETRIEVE_AFTER_TERM as number
)
) {
if (checkTerminated) this.lastErrorCode = "0";
try {
returnValue = this.getCMIValue(CMIElement);
} catch (e) {
returnValue = this.handleValueAccessException(e, returnValue);
}
this.processListeners(callbackName, CMIElement);
}
this.apiLog(
callbackName,
": returned: " + returnValue,
LogLevelEnum.INFO,
CMIElement
);
if (returnValue === undefined) {
return "";
}
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: string,
value: any
): string {
if (value !== undefined) {
value = String(value);
}
let returnValue: string = global_constants.SCORM_FALSE;
if (
this.checkState(
checkTerminated,
this._error_codes.STORE_BEFORE_INIT as number,
this._error_codes.STORE_AFTER_TERM as number
)
) {
if (checkTerminated) this.lastErrorCode = "0";
try {
returnValue = this.setCMIValue(CMIElement, value);
} catch (e) {
this.handleValueAccessException(e, returnValue);
}
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.scheduleCommit(
this.settings.autocommitSeconds * 1000,
commitCallback
);
}
}
this.apiLog(
callbackName,
": " + value + ": result: " + returnValue,
LogLevelEnum.INFO,
CMIElement
);
this.clearSCORMError(returnValue);
return returnValue;
}
/**
* Orders LMS to store all content parameters
* @param {string} callbackName
* @param {boolean} checkTerminated
* @return {string}
*/
async commit(
callbackName: string,
checkTerminated: boolean = false
): Promise<string> {
this.clearScheduledCommit();
let returnValue: string = global_constants.SCORM_FALSE;
if (
this.checkState(
checkTerminated,
this._error_codes.COMMIT_BEFORE_INIT as number,
this._error_codes.COMMIT_AFTER_TERM as number
)
) {
const result = await this.storeData(false);
if (result.errorCode && result.errorCode > 0) {
this.throwSCORMError(result.errorCode);
}
returnValue =
typeof result !== "undefined" && (result.result === true || result.result === global_constants.SCORM_TRUE)
? global_constants.SCORM_TRUE
: global_constants.SCORM_FALSE;
this.apiLog(
callbackName,
" Result: " + returnValue,
LogLevelEnum.DEBUG,
"HttpRequest"
);
if (checkTerminated) this.lastErrorCode = "0";
this.processListeners(callbackName);
}
this.apiLog(callbackName, "returned: " + returnValue, LogLevelEnum.INFO);
this.clearSCORMError(returnValue);
return returnValue;
}
/**
* Returns last error code
* @param {string} callbackName
* @return {string}
*/
getLastError(callbackName: string): string {
const returnValue = String(this.lastErrorCode);
this.processListeners(callbackName);
this.apiLog(callbackName, "returned: " + returnValue, LogLevelEnum.INFO);
return returnValue;
}
/**
* Returns the errorNumber error description
*
* @param {string} callbackName
* @param {(string|number)} CMIErrorCode
* @return {string}
*/
getErrorString(callbackName: string, CMIErrorCode: string | number): string {
let returnValue = "";
if (CMIErrorCode !== null && CMIErrorCode !== "") {
returnValue = this.getLmsErrorMessageDetails(CMIErrorCode);
this.processListeners(callbackName);
}
this.apiLog(callbackName, "returned: " + returnValue, LogLevelEnum.INFO);
return returnValue;
}
/**
* Returns a comprehensive description of the errorNumber error.
*
* @param {string} callbackName
* @param {(string|number)} CMIErrorCode
* @return {string}
*/
getDiagnostic(callbackName: string, CMIErrorCode: string | number): string {
let returnValue = "";
if (CMIErrorCode !== null && CMIErrorCode !== "") {
returnValue = this.getLmsErrorMessageDetails(CMIErrorCode, true);
this.processListeners(callbackName);
}
this.apiLog(callbackName, "returned: " + returnValue, LogLevelEnum.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
): boolean {
if (this.isNotInitialized()) {
this.throwSCORMError(beforeInitError);
return false;
} else if (checkTerminated && this.isTerminated()) {
this.throwSCORMError(afterTermError);
return false;
}
return true;
}
/**
* 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: string | number,
_detail: boolean = false
): string {
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: string): string {
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: string, _value: any): string {
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 {any} value
* @return {string}
*/
_commonSetCMIValue(
methodName: string,
scorm2004: boolean,
CMIElement: string,
value: any
): string {
if (!CMIElement || CMIElement === "") {
return global_constants.SCORM_FALSE;
}
const structure = CMIElement.split(".");
let refObject: 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 idx = 0; idx < structure.length; idx++) {
const attribute = structure[idx];
if (!attribute) {
this.throwSCORMError(invalidErrorCode as number, invalidErrorMessage);
return global_constants.SCORM_FALSE;
}
if (idx === structure.length - 1) {
if (scorm2004 && attribute.substring(0, 8) === "{target=") {
if (this.isInitialized()) {
this.throwSCORMError(this._error_codes.READ_ONLY_ELEMENT as number);
} else {
refObject = {
...refObject,
attribute: value
};
}
} else if (!this._checkObjectHasProperty(refObject, attribute)) {
this.throwSCORMError(invalidErrorCode as number, invalidErrorMessage);
} else {
if (
stringMatches(CMIElement, "\\.correct_responses\\.\\d+") &&
this.isInitialized()
) {
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 as number, invalidErrorMessage);
break;
}
if (refObject instanceof CMIArray) {
const index = parseInt(structure[idx + 1] || "0", 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 as number,
invalidErrorMessage
);
} else {
if (refObject.initialized) newChild.initialize();
refObject.childArray.push(newChild);
refObject = newChild;
}
}
// Have to update idx value to skip the array position
idx++;
}
}
}
}
if (returnValue === global_constants.SCORM_FALSE) {
this.apiLog(
methodName,
`There was an error setting the value for: ${CMIElement}, value of: ${value}`,
LogLevelEnum.WARN
);
}
return returnValue;
}
/**
* Gets a value from the CMI Object
*
* @param {string} methodName
* @param {boolean} scorm2004
* @param {string} CMIElement
* @return {any}
*/
_commonGetCMIValue(
methodName: string,
scorm2004: boolean,
CMIElement: string
): any {
if (!CMIElement || CMIElement === "") {
return "";
}
const structure = CMIElement.split(".");
let refObject: 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 idx = 0; idx < structure.length; idx++) {
attribute = structure[idx];
if (!attribute) {
this.throwSCORMError(invalidErrorCode as number, invalidErrorMessage);
return;
}
if (!scorm2004) {
if (idx === structure.length - 1) {
if (!this._checkObjectHasProperty(refObject, attribute)) {
this.throwSCORMError(
invalidErrorCode as number,
invalidErrorMessage
);
return;
}
}
} else {
if (
String(attribute).substring(0, 8) === "{target=" &&
typeof refObject._isTargetValid == "function"
) {
const target = String(attribute).substring(
8,
String(attribute).length - 9
);
return refObject._isTargetValid(target);
} else if (!this._checkObjectHasProperty(refObject, attribute)) {
this.throwSCORMError(invalidErrorCode as number, invalidErrorMessage);
return;
}
}
refObject = refObject[attribute];
if (refObject === undefined) {
this.throwSCORMError(invalidErrorCode as number, invalidErrorMessage);
break;
}
if (refObject instanceof CMIArray) {
const index = parseInt(structure[idx + 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 as number,
uninitializedErrorMessage
);
break;
}
// Have to update idx value to skip the array position
idx++;
}
}
}
if (refObject === null || refObject === undefined) {
if (!scorm2004) {
if (attribute === "_children") {
this.throwSCORMError(this._error_codes.CHILDREN_ERROR as number);
} else if (attribute === "_count") {
this.throwSCORMError(this._error_codes.COUNT_ERROR as number);
}
}
} else {
return refObject;
}
}
/**
* Returns true if the API's current state is STATE_INITIALIZED
*
* @return {boolean}
*/
isInitialized(): boolean {
return this.currentState === global_constants.STATE_INITIALIZED;
}
/**
* Returns true if the API's current state is STATE_NOT_INITIALIZED
*
* @return {boolean}
*/
isNotInitialized(): boolean {
return this.currentState === global_constants.STATE_NOT_INITIALIZED;
}
/**
* Returns true if the API's current state is STATE_TERMINATED
*
* @return {boolean}
*/
isTerminated(): boolean {
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 || listenerSplit.length === 0) return;
const functionName = listenerSplit[0];
let CMIElement = null;
if (listenerSplit.length > 1) {
CMIElement = listenerFunctions[i]?.replace(functionName + ".", "");
}
this.listenerArray.push({
functionName: functionName,
CMIElement: CMIElement,
callback: callback
});
this.apiLog(
"on",
`Added event listener: ${this.listenerArray.length}`,
LogLevelEnum.INFO,
functionName
);
}
}
/**
* 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 || listenerSplit.length === 0) return;
const functionName = listenerSplit[0];
let CMIElement = null;
if (listenerSplit.length > 1) {
CMIElement = listenerFunctions[i]?.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",
`Removed event listener: ${this.listenerArray.length}`,
LogLevelEnum.INFO,
functionName
);
}
}
}
/**
* 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 || listenerSplit?.length === 0) return;
const functionName = listenerSplit[0];
let CMIElement = null;
if (listenerSplit.length > 1) {
CMIElement = listenerFunctions[i]?.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 {any} value
*/
processListeners(functionName: string, CMIElement?: string, value?: any) {
this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
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)) {
this.apiLog(
"processListeners",
`Processing listener: ${listener.functionName}`,
LogLevelEnum.INFO,
CMIElement
);
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",
errorNumber + ": " + message,
LogLevelEnum.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";
}
}
/**
* Load the CMI from a flattened JSON object
* @param {RefObject} json
* @param {string} CMIElement
*/
loadFromFlattenedJSON(json: RefObject, CMIElement?: string) {
if (!CMIElement) {
// by default, we start from a blank string because we're expecting each element to start with `cmi`
CMIElement = "";
}
if (!this.isNotInitialized()) {
console.error(
"loadFromFlattenedJSON can only be called before the call to lmsInitialize."
);
return;
}
/**
* Tests two strings against a given regular expression pattern and determines a numeric or null result based on the matching criterion.
*
* @param {string} a - The first string to be tested against the pattern.
* @param {string} c - The second string to be tested against the pattern.
* @param {RegExp} a_pattern - The regular expression pattern to test the strings against.
* @return {number | null} A numeric result based on the matching criterion, or null if the strings do not match the pattern.
*/
function testPattern(
a: string,
c: string,
a_pattern: RegExp
): number | null {
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: RefObject;
result.forEach((element) => {
obj = {};
obj[element[0]] = element[1];
this.loadFromJSON(unflatten(obj), CMIElement);
});
}
/**
* Loads CMI data from a JSON object.
*
* @param {RefObject} json
* @param {string} CMIElement
*/
loadFromJSON(json: RefObject, CMIElement: string = "") {
if (
(!CMIElement || CMIElement === "") &&
!Object.hasOwnProperty.call(json, "cmi") &&
!Object.hasOwnProperty.call(json, "adl")
) {
// providing a backward compatibility for the old v1 API
CMIElement = "cmi";
}
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(): string {
const cmi = this.cmi;
// Do we want/need to return fields that have no set value?
if (this.settings.sendFullCommit) {
return JSON.stringify({ cmi });
}
return JSON.stringify({ cmi }, (k, v) => (v === undefined ? null : v), 2);
}
/**
* Returns a JS object representing the current cmi
* @return {object}
*/
renderCMIToJSONObject(): object {
return JSON.parse(this.renderCMIToJSONString()) as object;
}
/**
* Send the request to the LMS
* @param {string} url
* @param {CommitObject|RefObject|Array} params
* @param {boolean} immediate
* @return {ResultObject}
*/
async processHttpRequest(
url: string,
params: CommitObject | RefObject | Array<any>,
immediate: boolean = false
): Promise<ResultObject> {
const api = this;
const genericError: ResultObject = {
result: global_constants.SCORM_FALSE,
errorCode: this.error_codes.GENERAL as number
};
// if we are terminating the module or closing the browser window/tab, we need to make this fetch ASAP.
// Some browsers, especially Chrome, do not like synchronous requests to be made when the window is closing.
if (immediate) {
// Apply requestHandler even for immediate requests
params = this.settings.requestHandler(params);
this.performFetch(url, params).then(async (response) => {
await this.transformResponse(response);
});
return {
result: global_constants.SCORM_TRUE,
errorCode: 0
};
}
const process = async (
url: string,
params: CommitObject | RefObject | Array<any>,
settings: InternalSettings
): Promise<ResultObject> => {
try {
params = settings.requestHandler(params);
const response = await this.performFetch(url, params);
return this.transformResponse(response);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
this.apiLog("processHttpRequest", message, LogLevelEnum.ERROR);
api.processListeners("CommitError");
return genericError;
}
};
return await process(url, params, this.settings);
}
/**
* 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) {
if (!this._timeout) {
this._timeout = new ScheduledCommit(this, when, callback);
this.apiLog("scheduleCommit", "scheduled", LogLevelEnum.DEBUG, "");
}
}
/**
* Clears and cancels any currently scheduled commits
*/
clearScheduledCommit() {
if (this._timeout) {
this._timeout.cancel();
this._timeout = undefined;
this.apiLog("clearScheduledCommit", "cleared", LogLevelEnum.DEBUG, "");
}
}
/**
* Check to see if the specific object has the given property
* @param {RefObject} refObject
* @param {string} attribute
* @return {boolean}
* @private
*/
private _checkObjectHasProperty(
refObject: RefObject,
attribute: string
): boolean {
return (
Object.hasOwnProperty.call(refObject, attribute) ||
Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(refObject),
attribute
) != null ||
attribute in refObject
);
}
/**
* Handles the error that occurs when trying to access a value
* @param {any} e
* @param {string} returnValue
* @return {string}
* @private
*/
private handleValueAccessException(e: any, returnValue: string): string {
if (e instanceof ValidationError) {
this.lastErrorCode = String(e.errorCode);
returnValue = global_constants.SCORM_FALSE;
} else {
if (e instanceof Error && e.message) {
console.error(e.message);
} else {
console.error(e);
}
this.throwSCORMError(this._error_codes.GENERAL as number);
}
return returnValue;
}
/**
* Builds the commit object to be sent to the LMS
* @param {boolean} terminateCommit
* @return {CommitObject|RefObject|Array}
* @private
*/
protected getCommitObject(
terminateCommit: boolean
): CommitObject | RefObject | Array<any> {
const shouldTerminateCommit =
terminateCommit || this.settings.alwaysSendTotalTime;
const commitObject = this.settings.renderCommonCommitFields
? this.renderCommitObject(shouldTerminateCommit)
: this.renderCommitCMI(shouldTerminateCommit);
if ([LogLevelEnum.DEBUG, "1", 1, "DEBUG"].includes(this.apiLogLevel)) {
console.debug(
"Commit (terminated: " + (terminateCommit ? "yes" : "no") + "): "
);
console.debug(commitObject);
}
return commitObject;
}
/**
* Perform the fetch request to the LMS
* @param {string} url
* @param {RefObject|Array} params
* @return {Promise<Response>}
* @private
*/
private async performFetch(
url: string,
params: RefObject | Array<any>
): Promise<Response> {
let init = {
method: "POST",
mode: this.settings.fetchMode,
body: params instanceof Array ? params.join("&") : JSON.stringify(params),
headers: {
...this.settings.xhrHeaders,
"Content-Type": this.settings.commitRequestDataType
},
keepalive: true
} as RequestInit;
if (this.settings.xhrWithCredentials) {
init = {
...init,
credentials: "include"
};
}
return fetch(url, init);
}
/**
* Transforms the response from the LMS to a ResultObject
* @param {Response} response
* @return {Promise<ResultObject>}
* @private
*/
private async transformResponse(response: Response): Promise<ResultObject> {
const result: ResultObject =
typeof this.settings.responseHandler === "function"
? await this.settings.responseHandler(response)
: await response.json();
if (
response.status >= 200 &&
response.status <= 299 &&
(result.result === true || result.result === global_constants.SCORM_TRUE)
) {
this.processListeners("CommitSuccess");
} else {
this.processListeners("CommitError");
}
return result;
}
}