UNPKG

scorm-again

Version:

A modern SCORM JavaScript run-time library for AICC, SCORM 1.2, and SCORM 2004

826 lines (754 loc) 24.4 kB
import BaseAPI from "./BaseAPI"; import { CMI } from "./cmi/scorm2004/cmi"; import * as Utilities from "./utilities"; import { stringMatches } from "./utilities"; import { global_constants, scorm2004_constants } from "./constants/api_constants"; import { scorm2004_errors } from "./constants/error_codes"; import { CorrectResponses, ResponseType } from "./constants/response_constants"; import ValidLanguages from "./constants/language_constants"; import { CMIArray } from "./cmi/common/array"; import { BaseCMI } from "./cmi/common/base_cmi"; import { CMIInteractionsCorrectResponsesObject, CMIInteractionsObject, CMIInteractionsObjectivesObject } from "./cmi/scorm2004/interactions"; import { CMICommentsObject } from "./cmi/scorm2004/comments"; import { CMIObjectivesObject } from "./cmi/scorm2004/objectives"; import { ADL, ADLDataObject } from "./cmi/scorm2004/adl"; import { CommitObject, RefObject, ResultObject, ScoreObject, Settings } from "./types/api_types"; import { CompletionStatus, SuccessStatus } from "./constants/enums"; import { scorm2004_regex } from "./constants/regex"; /** * API class for SCORM 2004 */ class Scorm2004Impl extends BaseAPI { private _version: string = "1.0"; private _globalObjectives: CMIObjectivesObject[] = []; /** * Constructor for SCORM 2004 API * @param {Settings} settings */ constructor(settings?: Settings) { if (settings) { if (settings.mastery_override === undefined) { settings.mastery_override = false; } } super(scorm2004_errors, settings); this.cmi = new CMI(); this.adl = new ADL(); // Rename functions to match 2004 Spec and expose to modules this.Initialize = this.lmsInitialize; this.Terminate = this.lmsFinish; this.GetValue = this.lmsGetValue; this.SetValue = this.lmsSetValue; this.Commit = this.lmsCommit; this.GetLastError = this.lmsGetLastError; this.GetErrorString = this.lmsGetErrorString; this.GetDiagnostic = this.lmsGetDiagnostic; } public cmi: CMI; public adl: ADL; public Initialize: () => string; public Terminate: () => string; public GetValue: (CMIElement: string) => string; public SetValue: (CMIElement: string, value: any) => string; public Commit: () => string; public GetLastError: () => string; public GetErrorString: (CMIErrorCode: string | number) => string; public GetDiagnostic: (CMIErrorCode: string | number) => string; /** * Called when the API needs to be reset */ reset(settings?: Settings) { this.commonReset(settings); this.cmi?.reset(); this.adl?.reset(); } /** * Getter for _version * @return {string} */ get version(): string { return this._version; } /** * Getter for _globalObjectives */ get globalObjectives(): CMIObjectivesObject[] { return this._globalObjectives; } /** * @return {string} bool */ lmsInitialize(): string { this.cmi.initialize(); return this.initialize("Initialize"); } /** * @return {string} bool */ lmsFinish(): string { (async () => { await this.internalFinish(); })(); return global_constants.SCORM_TRUE; } async internalFinish(): Promise<string> { const result = await this.terminate("Terminate", true); if (result === global_constants.SCORM_TRUE) { if (this.adl.nav.request !== "_none_") { const navActions: { [key: string]: string } = { continue: "SequenceNext", previous: "SequencePrevious", choice: "SequenceChoice", jump: "SequenceJump", exit: "SequenceExit", exitAll: "SequenceExitAll", abandon: "SequenceAbandon", abandonAll: "SequenceAbandonAll", }; let request = this.adl.nav.request; const choiceJumpRegex = new RegExp(scorm2004_regex.NAVEvent); const matches = request.match(choiceJumpRegex); let target = ""; if (matches) { if (matches.groups?.choice_target) { target = matches.groups?.choice_target; request = "choice"; } else if (matches.groups?.jump_target) { target = matches.groups?.jump_target; request = "jump"; } } const action = navActions[request]; if (action) { this.processListeners(action, "adl.nav.request", target); } } else if (this.settings.autoProgress) { this.processListeners("SequenceNext"); } } return result; } /** * @param {string} CMIElement * @return {string} */ lmsGetValue(CMIElement: string): string { const adlNavRequestRegex = "^adl\\.nav\\.request_valid\\.(choice|jump)\\.{target=\\S{0,}([a-zA-Z0-9-_]+)}$"; if (stringMatches(CMIElement, adlNavRequestRegex)) { const matches = CMIElement.match(adlNavRequestRegex); if (matches) { const request = matches[1]; const target = matches[2]?.replace("{target=", "").replace("}", ""); if (target && (request === "choice" || request === "jump")) { if (this.settings.scoItemIdValidator) { return String(this.settings.scoItemIdValidator(target)); } if (this.settings.scoItemIds) { return String(this.settings.scoItemIds?.includes(target)); } return String(request); } } } return this.getValue("GetValue", true, CMIElement); } /** * @param {string} CMIElement * @param {any} value * @return {string} */ lmsSetValue(CMIElement: string, value: any): string { // Proceed with regular setting for non-objective elements or fallback behavior return this.setValue("SetValue", "Commit", true, CMIElement, value); } /** * Orders LMS to store all content parameters * * @return {string} bool */ lmsCommit(): string { if (this.settings.asyncCommit) { this.scheduleCommit(500, "LMSCommit"); } else { (async () => { await this.commit("LMSCommit", false); })(); } return global_constants.SCORM_TRUE; } /** * Returns last error code * * @return {string} */ lmsGetLastError(): string { return this.getLastError("GetLastError"); } /** * Returns the errorNumber error description * * @param {(string|number)} CMIErrorCode * @return {string} */ lmsGetErrorString(CMIErrorCode: string | number): string { return this.getErrorString("GetErrorString", CMIErrorCode); } /** * Returns a comprehensive description of the errorNumber error. * * @param {(string|number)} CMIErrorCode * @return {string} */ lmsGetDiagnostic(CMIErrorCode: string | number): string { return this.getDiagnostic("GetDiagnostic", CMIErrorCode); } /** * Sets a value on the CMI Object * * @param {string} CMIElement * @param {any} value * @return {string} */ override setCMIValue(CMIElement: string, value: any): string { // Check if we're updating a global or local objective if (stringMatches(CMIElement, "cmi\\.objectives\\.\\d+")) { const parts = CMIElement.split("."); const index = Number(parts[2]); const element_base = `cmi.objectives.${index}`; let objective_id; const setting_id = stringMatches( CMIElement, "cmi\\.objectives\\.\\d+\\.id", ); if (setting_id) { // If we're setting the objective ID, capture it directly objective_id = value; } else { // Find existing objective ID if available const objective = this.cmi.objectives.findObjectiveByIndex(index); objective_id = objective ? objective.id : undefined; } // Check if the objective ID matches a global objective const is_global = objective_id && this.settings.globalObjectiveIds?.includes(objective_id); if (is_global) { // Locate or create an entry in _globalObjectives for the global objective let global_index = this._globalObjectives.findIndex( (obj) => obj.id === objective_id, ); if (global_index === -1) { global_index = this._globalObjectives.length; const newGlobalObjective = new CMIObjectivesObject(); newGlobalObjective.id = objective_id; this._globalObjectives.push(newGlobalObjective); } // Update the global objective const global_element = CMIElement.replace( element_base, `_globalObjectives.${global_index}`, ); this._commonSetCMIValue( "SetGlobalObjectiveValue", true, global_element, value, ); } } return this._commonSetCMIValue("SetValue", true, CMIElement, value); } /** * Gets or builds a new child element to add to the array. * * @param {string} CMIElement * @param {any} value * @param {boolean} foundFirstIndex * @return {BaseCMI|null} */ getChildElement( CMIElement: string, value: any, foundFirstIndex: boolean, ): BaseCMI | null { if (stringMatches(CMIElement, "cmi\\.objectives\\.\\d+")) { return new CMIObjectivesObject(); } if (foundFirstIndex) { if ( stringMatches( CMIElement, "cmi\\.interactions\\.\\d+\\.correct_responses\\.\\d+", ) ) { return this.createCorrectResponsesObject(CMIElement, value); } else if ( stringMatches( CMIElement, "cmi\\.interactions\\.\\d+\\.objectives\\.\\d+", ) ) { return new CMIInteractionsObjectivesObject(); } } else if (stringMatches(CMIElement, "cmi\\.interactions\\.\\d+")) { return new CMIInteractionsObject(); } if (stringMatches(CMIElement, "cmi\\.comments_from_learner\\.\\d+")) { return new CMICommentsObject(); } else if (stringMatches(CMIElement, "cmi\\.comments_from_lms\\.\\d+")) { return new CMICommentsObject(true); } if (stringMatches(CMIElement, "adl\\.data\\.\\d+")) { return new ADLDataObject(); } return null; } private createCorrectResponsesObject( CMIElement: string, value: any, ): BaseCMI | null { const parts = CMIElement.split("."); const index = Number(parts[2]); const interaction = this.cmi.interactions.childArray[index]; if (this.isInitialized()) { if (!interaction.type) { this.throwSCORMError( scorm2004_errors.DEPENDENCY_NOT_ESTABLISHED as number, ); } else { this.checkDuplicateChoiceResponse(interaction, value); const response_type = CorrectResponses[interaction.type]; if (response_type) { this.checkValidResponseType(response_type, value, interaction.type); } else { this.throwSCORMError( scorm2004_errors.GENERAL_SET_FAILURE as number, "Incorrect Response Type: " + interaction.type, ); } } } if (this.lastErrorCode === "0") { return new CMIInteractionsCorrectResponsesObject(); } return null; } /** * Checks for valid response types * @param {object} response_type * @param {any} value * @param {string} interaction_type */ checkValidResponseType( response_type: ResponseType, value: any, interaction_type: string, ) { let nodes = []; if (response_type?.delimiter) { nodes = String(value).split(response_type.delimiter); } else { nodes[0] = value; } if (nodes.length > 0 && nodes.length <= response_type.max) { this.checkCorrectResponseValue(interaction_type, nodes, value); } else if (nodes.length > response_type.max) { this.throwSCORMError( scorm2004_errors.GENERAL_SET_FAILURE as number, "Data Model Element Pattern Too Long", ); } } /** * Checks for duplicate 'choice' responses. * @param {CMIInteractionsObject} interaction * @param {any} value */ checkDuplicateChoiceResponse(interaction: CMIInteractionsObject, value: any) { const interaction_count = interaction.correct_responses._count; if (interaction.type === "choice") { for ( let i = 0; i < interaction_count && this.lastErrorCode === "0"; i++ ) { const response = interaction.correct_responses.childArray[i]; if (response.pattern === value) { this.throwSCORMError(scorm2004_errors.GENERAL_SET_FAILURE as number); } } } } /** * Validate correct response. * @param {string} CMIElement * @param {*} value */ validateCorrectResponse(CMIElement: string, value: any) { const parts = CMIElement.split("."); const index = Number(parts[2]); const pattern_index = Number(parts[4]); const interaction = this.cmi.interactions.childArray[index]; const interaction_count = interaction.correct_responses._count; this.checkDuplicateChoiceResponse(interaction, value); const response_type = CorrectResponses[interaction.type]; if ( typeof response_type !== "undefined" && (typeof response_type.limit === "undefined" || interaction_count <= response_type.limit) ) { this.checkValidResponseType(response_type, value, interaction.type); if ( (this.lastErrorCode === "0" && (!response_type.duplicate || !this.checkDuplicatedPattern( interaction.correct_responses, pattern_index, value, ))) || (this.lastErrorCode === "0" && value === "") ) { // do nothing, we want the inverse } else { if (this.lastErrorCode === "0") { this.throwSCORMError( scorm2004_errors.GENERAL_SET_FAILURE as number, "Data Model Element Pattern Already Exists", ); } } } else { this.throwSCORMError( scorm2004_errors.GENERAL_SET_FAILURE as number, "Data Model Element Collection Limit Reached", ); } } /** * Gets a value from the CMI Object * * @param {string} CMIElement * @return {*} */ override getCMIValue(CMIElement: string): any { return this._commonGetCMIValue("GetValue", true, CMIElement); } /** * Returns the message that corresponds to errorNumber. * * @param {(string|number)} errorNumber * @param {boolean} detail * @return {string} */ override getLmsErrorMessageDetails( errorNumber: string | number, detail: boolean, ): string { let basicMessage = ""; let detailMessage = ""; // Set error number to string since inconsistent from modules if string or number errorNumber = String(errorNumber); if (scorm2004_constants.error_descriptions[errorNumber]) { basicMessage = scorm2004_constants.error_descriptions[errorNumber]?.basicMessage || "Unknown Error"; detailMessage = scorm2004_constants.error_descriptions[errorNumber]?.detailMessage || ""; } return detail ? detailMessage : basicMessage; } /** * Check to see if a correct_response value has been duplicated * @param {CMIArray} correct_response * @param {number} current_index * @param {*} value * @return {boolean} */ checkDuplicatedPattern( correct_response: CMIArray, current_index: number, value: any, ): boolean { let found = false; const count = correct_response._count; for (let i = 0; i < count && !found; i++) { if (i !== current_index && correct_response.childArray[i] === value) { found = true; } } return found; } /** * Checks for a valid correct_response value * @param {string} interaction_type * @param {Array} nodes * @param {*} value */ checkCorrectResponseValue( interaction_type: string, nodes: Array<any>, value: any, ) { const response = CorrectResponses[interaction_type]; const formatRegex = new RegExp(response?.format || ".*"); for (let i = 0; i < nodes.length && this.lastErrorCode === "0"; i++) { if ( interaction_type.match( "^(fill-in|long-fill-in|matching|performance|sequencing)$", ) ) { nodes[i] = this.removeCorrectResponsePrefixes(nodes[i]); } if (response?.delimiter2) { const values = nodes[i].split(response.delimiter2); if (values.length === 2) { const matches = values[0].match(formatRegex); if (!matches) { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } else { if ( !response.format2 || !values[1].match(new RegExp(response.format2)) ) { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } } } else { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } } else { const matches = nodes[i].match(formatRegex); if ( (!matches && value !== "") || (!matches && interaction_type === "true-false") ) { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } else { if (interaction_type === "numeric" && nodes.length > 1) { if (Number(nodes[0]) > Number(nodes[1])) { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } } else { if (nodes[i] !== "" && response?.unique) { for (let j = 0; j < i && this.lastErrorCode === "0"; j++) { if (nodes[i] === nodes[j]) { this.throwSCORMError( scorm2004_errors.TYPE_MISMATCH as number, ); } } } } } } } } /** * Remove prefixes from correct_response * @param {string} node * @return {*} */ removeCorrectResponsePrefixes(node: string): any { let seenOrder = false; let seenCase = false; let seenLang = false; const prefixRegex = new RegExp( "^({(lang|case_matters|order_matters)=([^}]+)})", ); let matches = node.match(prefixRegex); let langMatches = null; while (matches) { switch (matches[2]) { case "lang": langMatches = node.match(scorm2004_regex.CMILangcr); if (langMatches) { const lang = langMatches[3]; if (lang !== undefined && lang.length > 0) { if (!ValidLanguages.includes(lang.toLowerCase())) { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } } } seenLang = true; break; case "case_matters": if (!seenLang && !seenOrder && !seenCase) { if (matches[3] !== "true" && matches[3] !== "false") { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } } seenCase = true; break; case "order_matters": if (!seenCase && !seenLang && !seenOrder) { if (matches[3] !== "true" && matches[3] !== "false") { this.throwSCORMError(scorm2004_errors.TYPE_MISMATCH as number); } } seenOrder = true; break; } node = node.substring(matches[1]?.length || 0); matches = node.match(prefixRegex); } return node; } /** * Replace the whole API with another * @param {Scorm2004Impl} newAPI */ replaceWithAnotherScormAPI(newAPI: Scorm2004Impl) { // Data Model this.cmi = newAPI.cmi; this.adl = newAPI.adl; } /** * Render the cmi object to the proper format for LMS commit * * @param {boolean} terminateCommit * @return {object|Array} */ renderCommitCMI(terminateCommit: boolean): object | Array<any> { const cmiExport: RefObject = this.renderCMIToJSONObject(); if (terminateCommit) { cmiExport.cmi.total_time = this.cmi.getCurrentTotalTime(); } const result = []; const flattened: RefObject = Utilities.flatten(cmiExport); switch (this.settings.dataCommitFormat) { case "flattened": return Utilities.flatten(cmiExport); case "params": for (const item in flattened) { if ({}.hasOwnProperty.call(flattened, item)) { result.push(`${item}=${flattened[item]}`); } } return result; case "json": default: return cmiExport; } } /** * Render the cmi object to the proper format for LMS commit * @param {boolean} terminateCommit * @return {CommitObject} */ renderCommitObject(terminateCommit: boolean): CommitObject { const cmiExport = this.renderCommitCMI(terminateCommit); const totalTimeDuration = this.cmi.getCurrentTotalTime(); const totalTimeSeconds = Utilities.getDurationAsSeconds( totalTimeDuration, scorm2004_regex.CMITimespan, ); let completionStatus = CompletionStatus.unknown; let successStatus = SuccessStatus.unknown; if (this.cmi.completion_status) { if (this.cmi.completion_status === "completed") { completionStatus = CompletionStatus.completed; } else if (this.cmi.completion_status === "incomplete") { completionStatus = CompletionStatus.incomplete; } } if (this.cmi.success_status) { if (this.cmi.success_status === "passed") { successStatus = SuccessStatus.passed; } else if (this.cmi.success_status === "failed") { successStatus = SuccessStatus.failed; } } const score = this.cmi.score; const scoreObject: ScoreObject = {}; if (score) { if (!Number.isNaN(Number.parseFloat(score.raw))) { scoreObject.raw = Number.parseFloat(score.raw); } if (!Number.isNaN(Number.parseFloat(score.min))) { scoreObject.min = Number.parseFloat(score.min); } if (!Number.isNaN(Number.parseFloat(score.max))) { scoreObject.max = Number.parseFloat(score.max); } if (!Number.isNaN(Number.parseFloat(score.scaled))) { scoreObject.scaled = Number.parseFloat(score.scaled); } } const commitObject: CommitObject = { completionStatus: completionStatus, successStatus: successStatus, totalTimeSeconds: totalTimeSeconds, runtimeData: cmiExport, }; if (scoreObject) { commitObject.score = scoreObject; } return commitObject; } /** * Attempts to store the data to the LMS * * @param {boolean} terminateCommit * @return {ResultObject} */ async storeData(terminateCommit: boolean): Promise<ResultObject> { if (terminateCommit) { if (this.cmi.mode === "normal") { if (this.cmi.credit === "credit") { if (this.cmi.completion_threshold && this.cmi.progress_measure) { if (this.cmi.progress_measure >= this.cmi.completion_threshold) { this.cmi.completion_status = "completed"; } else { this.cmi.completion_status = "incomplete"; } } if (this.cmi.scaled_passing_score && this.cmi.score.scaled) { if (this.cmi.score.scaled >= this.cmi.scaled_passing_score) { this.cmi.success_status = "passed"; } else { this.cmi.success_status = "failed"; } } } } } let navRequest = false; if ( this.adl.nav.request !== this.startingData?.adl?.nav?.request && this.adl.nav.request !== "_none_" ) { navRequest = true; } const commitObject = this.getCommitObject(terminateCommit); if (typeof this.settings.lmsCommitUrl === "string") { const result = await this.processHttpRequest( this.settings.lmsCommitUrl, commitObject, terminateCommit, ); // check if this is a sequencing call, and then call the necessary JS { if ( navRequest && result.navRequest !== undefined && result.navRequest !== "" ) { Function(`"use strict";(() => { ${result.navRequest} })()`)(); } } return result; } else { return { result: global_constants.SCORM_TRUE, errorCode: 0, }; } } } export { Scorm2004Impl as Scorm2004API };