UNPKG

scorm-again

Version:

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

1,382 lines (1,369 loc) 203 kB
const SECONDS_PER_MINUTE = 60; const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE; const getSecondsAsHHMMSS = (totalSeconds) => { if (!totalSeconds || totalSeconds <= 0) { return "00:00:00"; } const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR); const dateObj = new Date(totalSeconds * 1e3); const minutes = dateObj.getUTCMinutes(); const seconds = dateObj.getSeconds(); const ms = totalSeconds % 1; let msStr = ""; if (countDecimals(ms) > 0) { if (countDecimals(ms) > 2) { msStr = ms.toFixed(2); } else { msStr = String(ms); } msStr = "." + msStr.split(".")[1]; } return (hours + ":" + minutes + ":" + seconds).replace(/\b\d\b/g, "0$&") + msStr; }; const getTimeAsSeconds = memoize( (timeString, timeRegex) => { if (typeof timeString === "number" || typeof timeString === "boolean") { timeString = String(timeString); } if (typeof timeRegex === "string") { timeRegex = new RegExp(timeRegex); } if (!timeString) { return 0; } if (!timeString.match(timeRegex)) { if (/^\d+(?:\.\d+)?$/.test(timeString)) { return Number(timeString); } return 0; } const parts = timeString.split(":"); const hours = Number(parts[0]); const minutes = Number(parts[1]); const seconds = Number(parts[2]); return hours * 3600 + minutes * 60 + seconds; }, // Custom key function to handle RegExp objects which can't be stringified (timeString, timeRegex) => { const timeStr = typeof timeString === "string" ? timeString : String(timeString ?? ""); const regexStr = typeof timeRegex === "string" ? timeRegex : timeRegex?.toString() ?? ""; return `${timeStr}:${regexStr}`; } ); const getDurationAsSeconds = memoize( (duration, durationRegex) => { if (typeof durationRegex === "string") { durationRegex = new RegExp(durationRegex); } if (!duration || !duration?.match?.(durationRegex)) { return 0; } const [, years, months, weeks, days, hours, minutes, seconds] = new RegExp(durationRegex).exec?.(duration) ?? []; let result = 0; result += Number(seconds) || 0; result += Number(minutes) * 60 || 0; result += Number(hours) * 3600 || 0; result += Number(days) * (60 * 60 * 24) || 0; result += Number(weeks) * (60 * 60 * 24 * 7) || 0; result += Number(months) * (60 * 60 * 24 * 30) || 0; result += Number(years) * (60 * 60 * 24 * 365) || 0; return result; }, // Custom key function to handle RegExp objects which can't be stringified (duration, durationRegex) => { const durationStr = duration ?? ""; const regexStr = typeof durationRegex === "string" ? durationRegex : durationRegex?.toString() ?? ""; return `${durationStr}:${regexStr}`; } ); function addHHMMSSTimeStrings(first, second, timeRegex) { if (typeof timeRegex === "string") { timeRegex = new RegExp(timeRegex); } return getSecondsAsHHMMSS( getTimeAsSeconds(first, timeRegex) + getTimeAsSeconds(second, timeRegex) ); } function flatten(data) { const result = {}; function recurse(cur, prop) { if (Object(cur) !== cur) { result[prop] = cur; } else if (Array.isArray(cur)) { cur.forEach((item, i) => { recurse(item, `${prop}[${i}]`); }); if (cur.length === 0) result[prop] = []; } else { const keys = Object.keys(cur).filter((p) => Object.prototype.hasOwnProperty.call(cur, p)); const isEmpty = keys.length === 0; keys.forEach((p) => { recurse(cur[p], prop ? `${prop}.${p}` : p); }); if (isEmpty && prop) result[prop] = {}; } } recurse(data, ""); return result; } function unflatten(data) { if (Object(data) !== data || Array.isArray(data)) return data; const result = {}; const pattern = /\.?([^.[\]]+)|\[(\d+)]/g; Object.keys(data).filter((p) => Object.prototype.hasOwnProperty.call(data, p)).forEach((p) => { let cur = result; let prop = ""; const regex = new RegExp(pattern); Array.from( { length: p.match(new RegExp(pattern, "g"))?.length ?? 0 }, () => regex.exec(p) ).forEach((m) => { if (m) { cur = cur[prop] ?? (cur[prop] = m[2] ? [] : {}); prop = m[2] || m[1] || ""; } }); cur[prop] = data[p]; }); return result[""] ?? result; } function countDecimals(num) { if (Math.floor(num) === num || String(num)?.indexOf?.(".") < 0) return 0; const parts = num.toString().split(".")?.[1]; return parts?.length ?? 0; } function formatMessage(functionName, message, CMIElement) { const baseLength = 20; let messageString = functionName ? `${String(functionName).padEnd(baseLength)}: ` : ""; if (CMIElement) { const CMIElementBaseLength = 70; messageString += CMIElement; messageString = messageString.padEnd(CMIElementBaseLength); } messageString += message ?? ""; return messageString; } function stringMatches(str, tester) { if (typeof str !== "string") { return false; } return new RegExp(tester).test(str); } function memoize(fn, keyFn) { const cache = /* @__PURE__ */ new Map(); return ((...args) => { const key = keyFn ? keyFn(...args) : JSON.stringify(args); return cache.has(key) ? cache.get(key) : (() => { const result = fn(...args); cache.set(key, result); return result; })(); }); } class BaseCMI { /** * Flag used during JSON serialization to allow getter access without initialization checks. * When true, getters can be accessed before the API is initialized, which is necessary * for serializing the CMI data structure to JSON format. */ jsonString = false; _cmi_element; _initialized = false; /** * Constructor for BaseCMI * @param {string} cmi_element */ constructor(cmi_element) { this._cmi_element = cmi_element; } /** * Getter for _initialized * @return {boolean} */ get initialized() { return this._initialized; } /** * Called when the API has been initialized after the CMI has been created */ initialize() { this._initialized = true; } } class BaseRootCMI extends BaseCMI { _start_time; /** * Start time of the session * @type {number | undefined} * @protected */ get start_time() { return this._start_time; } /** * Setter for start_time. Can only be called once. */ setStartTime() { if (this._start_time === void 0) { this._start_time = (/* @__PURE__ */ new Date()).getTime(); } else { throw new Error("Start time has already been set."); } } } class BaseScormValidationError extends Error { constructor(CMIElement, errorCode) { super(`${CMIElement} : ${errorCode.toString()}`); this._errorCode = errorCode; Object.setPrototypeOf(this, BaseScormValidationError.prototype); } _errorCode; /** * Getter for _errorCode * @return {number} */ get errorCode() { return this._errorCode; } } class ValidationError extends BaseScormValidationError { /** * Constructor to take in an error message and code * @param {string} CMIElement * @param {number} errorCode * @param {string} errorMessage * @param {string} detailedMessage */ constructor(CMIElement, errorCode, errorMessage, detailedMessage) { super(CMIElement, errorCode); this.message = `${CMIElement} : ${errorMessage}`; this._errorMessage = errorMessage; if (detailedMessage) { this._detailedMessage = detailedMessage; } Object.setPrototypeOf(this, ValidationError.prototype); } _errorMessage; _detailedMessage = ""; /** * Getter for _errorMessage * @return {string} */ get errorMessage() { return this._errorMessage; } /** * Getter for _detailedMessage * @return {string} */ get detailedMessage() { return this._detailedMessage; } } const global_constants = { SCORM_TRUE: "true", SCORM_FALSE: "false", STATE_NOT_INITIALIZED: 0, STATE_INITIALIZED: 1, STATE_TERMINATED: 2 }; const scorm12_constants = { // Children lists cmi_children: "core,suspend_data,launch_data,comments,objectives,student_data,student_preference,interactions", core_children: "student_id,student_name,lesson_location,credit,lesson_status,entry,score,total_time,lesson_mode,exit,session_time", score_children: "raw,min,max", objectives_children: "id,score,status", correct_responses_children: "pattern", student_data_children: "mastery_score,max_time_allowed,time_limit_action", student_preference_children: "audio,language,speed,text", interactions_children: "id,objectives,time,type,correct_responses,weighting,student_response,result,latency", error_descriptions: { "0": { basicMessage: "No Error", detailMessage: "No error occurred, the previous API call was successful." }, "101": { basicMessage: "General Exception", detailMessage: "No specific error code exists to describe the error." }, "201": { basicMessage: "Invalid argument error", detailMessage: "Indicates that an argument represents an invalid data model element or is otherwise incorrect." }, "202": { basicMessage: "Element cannot have children", detailMessage: 'Indicates that LMSGetValue was called with a data model element name that ends in "_children" for a data model element that does not support the "_children" suffix.' }, "203": { basicMessage: "Element not an array - cannot have count", detailMessage: 'Indicates that LMSGetValue was called with a data model element name that ends in "_count" for a data model element that does not support the "_count" suffix.' }, "301": { basicMessage: "Not initialized", detailMessage: "Indicates that an API call was made before the call to lmsInitialize." }, "401": { basicMessage: "Not implemented error", detailMessage: "The data model element indicated in a call to LMSGetValue or LMSSetValue is valid, but was not implemented by this LMS. SCORM 1.2 defines a set of data model elements as being optional for an LMS to implement." }, "402": { basicMessage: "Invalid set value, element is a keyword", detailMessage: 'Indicates that LMSSetValue was called on a data model element that represents a keyword (elements that end in "_children" and "_count").' }, "403": { basicMessage: "Element is read only", detailMessage: "LMSSetValue was called with a data model element that can only be read." }, "404": { basicMessage: "Element is write only", detailMessage: "LMSGetValue was called on a data model element that can only be written to." }, "405": { basicMessage: "Incorrect Data Type", detailMessage: "LMSSetValue was called with a value that is not consistent with the data format of the supplied data model element." }, "407": { basicMessage: "Element Value Out Of Range", detailMessage: "The numeric value supplied to a LMSSetValue call is outside of the numeric range allowed for the supplied data model element." }, "408": { basicMessage: "Data Model Dependency Not Established", detailMessage: "Some data model elements cannot be set until another data model element was set. This error condition indicates that the prerequisite element was not set before the dependent element." } } }; const scorm12_errors$1 = scorm12_constants.error_descriptions; class Scorm12ValidationError extends ValidationError { /** * Constructor to take in an error code * @param {string} CMIElement * @param {number} errorCode */ constructor(CMIElement, errorCode) { if ({}.hasOwnProperty.call(scorm12_errors$1, String(errorCode))) { super( CMIElement, errorCode, scorm12_errors$1[String(errorCode)]?.basicMessage || "Unknown error", scorm12_errors$1[String(errorCode)]?.detailMessage ); } else { super( CMIElement, 101, scorm12_errors$1["101"]?.basicMessage, scorm12_errors$1["101"]?.detailMessage ); } Object.setPrototypeOf(this, Scorm12ValidationError.prototype); } } const global_errors = { GENERAL: 101, INITIALIZATION_FAILED: 101, INITIALIZED: 101, TERMINATED: 101, TERMINATION_FAILURE: 101, TERMINATION_BEFORE_INIT: 101, MULTIPLE_TERMINATION: 101, RETRIEVE_BEFORE_INIT: 101, RETRIEVE_AFTER_TERM: 101, STORE_BEFORE_INIT: 101, STORE_AFTER_TERM: 101, COMMIT_BEFORE_INIT: 101, COMMIT_AFTER_TERM: 101, ARGUMENT_ERROR: 101, CHILDREN_ERROR: 101, COUNT_ERROR: 101, GENERAL_GET_FAILURE: 101, GENERAL_SET_FAILURE: 101, GENERAL_COMMIT_FAILURE: 101, UNDEFINED_DATA_MODEL: 101, UNIMPLEMENTED_ELEMENT: 101, VALUE_NOT_INITIALIZED: 101, INVALID_SET_VALUE: 101, READ_ONLY_ELEMENT: 101, WRITE_ONLY_ELEMENT: 101, TYPE_MISMATCH: 101, VALUE_OUT_OF_RANGE: 101, DEPENDENCY_NOT_ESTABLISHED: 101 }; const scorm12_errors = { ...global_errors, RETRIEVE_BEFORE_INIT: 301, STORE_BEFORE_INIT: 301, COMMIT_BEFORE_INIT: 301, ARGUMENT_ERROR: 201, CHILDREN_ERROR: 202, COUNT_ERROR: 203, UNDEFINED_DATA_MODEL: 401, UNIMPLEMENTED_ELEMENT: 401, VALUE_NOT_INITIALIZED: 301, INVALID_SET_VALUE: 402, READ_ONLY_ELEMENT: 403, WRITE_ONLY_ELEMENT: 404, TYPE_MISMATCH: 405, VALUE_OUT_OF_RANGE: 407, DEPENDENCY_NOT_ESTABLISHED: 408 }; class CMIArray extends BaseCMI { _errorCode; _errorClass; __children; childArray; /** * Constructor cmi *.n arrays * @param {object} params */ constructor(params) { super(params.CMIElement); this.__children = params.children; this._errorCode = params.errorCode ?? scorm12_errors.GENERAL; this._errorClass = params.errorClass || BaseScormValidationError; this.childArray = []; } /** * Called when the API has been reset */ reset(wipe = false) { this._initialized = false; if (wipe) { this.childArray = []; } else { for (let i = 0; i < this.childArray.length; i++) { this.childArray[i]?.reset(); } } } /** * Getter for _children * @return {string} */ get _children() { return this.__children; } /** * Setter for _children. Just throws an error. * @param {string} _children */ set _children(_children) { throw new this._errorClass(this._cmi_element + "._children", this._errorCode); } /** * Getter for _count * @return {number} */ get _count() { return this.childArray.length; } /** * Setter for _count. Just throws an error. * @param {number} _count */ set _count(_count) { throw new this._errorClass(this._cmi_element + "._count", this._errorCode); } /** * toJSON for *.n arrays * @return {object} */ toJSON() { this.jsonString = true; const result = {}; for (let i = 0; i < this.childArray.length; i++) { result[i + ""] = this.childArray[i]; } this.jsonString = false; return result; } } const SuccessStatus = { PASSED: "passed", FAILED: "failed", UNKNOWN: "unknown" }; const CompletionStatus = { COMPLETED: "completed", INCOMPLETE: "incomplete", UNKNOWN: "unknown" }; const LogLevelEnum = { _: 0, DEBUG: 1, INFO: 2, WARN: 3, ERROR: 4, NONE: 5 }; const DefaultSettings = { autocommit: false, autocommitSeconds: 10, throttleCommits: false, useAsynchronousCommits: false, sendFullCommit: true, lmsCommitUrl: false, dataCommitFormat: "json", commitRequestDataType: "application/json;charset=UTF-8", autoProgress: false, logLevel: LogLevelEnum.ERROR, selfReportSessionTime: false, alwaysSendTotalTime: false, renderCommonCommitFields: false, autoCompleteLessonStatus: false, strict_errors: true, xhrHeaders: {}, xhrWithCredentials: false, fetchMode: "cors", asyncModeBeaconBehavior: "never", responseHandler: async function(response) { if (typeof response !== "undefined") { let httpResult = null; try { if (typeof response.json === "function") { httpResult = await response.json(); } else if (typeof response.text === "function") { const responseText = await response.text(); if (responseText) { httpResult = JSON.parse(responseText); } } } catch (e) { } if (httpResult === null || !{}.hasOwnProperty.call(httpResult, "result")) { if (response.status === 200) { return { result: global_constants.SCORM_TRUE, errorCode: 0 }; } else { return { result: global_constants.SCORM_FALSE, errorCode: 101 }; } } else { return { result: httpResult.result, errorCode: typeof httpResult.errorCode === "number" ? httpResult.errorCode : httpResult.result === true || httpResult.result === global_constants.SCORM_TRUE ? 0 : 101 }; } } return { result: global_constants.SCORM_FALSE, errorCode: 101 }; }, xhrResponseHandler: function(xhr) { if (typeof xhr !== "undefined") { let httpResult = null; if (xhr.status >= 200 && xhr.status <= 299) { try { httpResult = JSON.parse(xhr.responseText); } catch (e) { } if (httpResult === null || !{}.hasOwnProperty.call(httpResult, "result")) { return { result: global_constants.SCORM_TRUE, errorCode: 0 }; } return { result: httpResult.result, errorCode: typeof httpResult.errorCode === "number" ? httpResult.errorCode : httpResult.result === true || httpResult.result === global_constants.SCORM_TRUE ? 0 : 101 }; } else { return { result: global_constants.SCORM_FALSE, errorCode: 101 }; } } return { result: global_constants.SCORM_FALSE, errorCode: 101 }; }, requestHandler: function(commitObject) { return commitObject; }, onLogMessage: defaultLogHandler, mastery_override: false, score_overrides_status: false, completion_status_on_failed: "completed", scoItemIds: [], scoItemIdValidator: false, globalObjectiveIds: [], // Offline support settings enableOfflineSupport: false, courseId: "", syncOnInitialize: true, syncOnTerminate: true, maxSyncAttempts: 5, // Multi-SCO support settings scoId: "", autoPopulateCommitMetadata: false, // HTTP service settings httpService: null, // Global learner preferences settings globalStudentPreferences: false }; function defaultLogHandler(messageLevel, logMessage) { switch (messageLevel) { case "4": case 4: case "ERROR": case LogLevelEnum.ERROR: console.error(logMessage); break; case "3": case 3: case "WARN": case LogLevelEnum.WARN: console.warn(logMessage); break; case "2": case 2: case "INFO": case LogLevelEnum.INFO: console.info(logMessage); break; case "1": case 1: case "DEBUG": case LogLevelEnum.DEBUG: if (console.debug) { console.debug(logMessage); } else { console.log(logMessage); } break; } } const scorm12_regex = { /** CMIString256 - Character string, max 255 chars (RTE A.1) */ CMIString256: "^[\\s\\S]{0,255}$", /** CMIString4096 - Character string, max 4096 chars (RTE A.1) */ CMIString4096: "^[\\s\\S]{0,4096}$", /** * CMIString64000 - Extended character string, max 64000 chars * * SPEC COMPLIANCE NOTE: * The SCORM 1.2 specification defines cmi.suspend_data as CMIString4096 (max 4096 chars). * This implementation intentionally increases the limit to 64000 chars (matching SCORM 2004) * for the following reasons: * * 1. Modern content frequently exceeds 4096 chars due to JSON state serialization, * base64 encoding, complex bookmark data, and rich interaction tracking * 2. The 4096 limit was set in 2001 when content was simpler; modern authoring tools * routinely generate larger suspend_data * 3. Most LMS systems can handle larger values - the API shouldn't be the bottleneck * 4. Content that gets rejected has no recovery path, causing data loss * 5. Aligns with SCORM 2004's more practical 64000 char limit * * Used by: cmi.suspend_data (SCORM 1.2) * * Strict spec pattern would be: ^[\s\S]{0,4096}$ */ CMIString64000: "^[\\s\\S]{0,64000}$", /** * CMITime - Clock time in HH:MM:SS.SS format (RTE A.2) * Optional centiseconds (1-2 decimal digits) per spec. */ CMITime: "^(?:[01]\\d|2[0123]):(?:[012345]\\d):(?:[012345]\\d)(\\.\\d{1,2})?$", /** * CMITimespan - Time interval in HHHH:MM:SS.SS format (RTE A.3) * We allow more digits for the hour to support values generated * by getSecondsAsHHMMSS which can produce larger hour values * (e.g., 17496:00:00 for very long durations). * Changed from minimum 2 digits to 1+ digits with no upper limit. */ CMITimespan: "^([0-9]+):([0-9]{2}):([0-9]{2})(\\.\\d{1,2})?$", /** * CMIInteger - Non-negative integer (RTE A.4) * * SPEC COMPLIANCE NOTE: * The SCORM 1.2 specification defines CMIInteger as 0-65536 range. * This implementation intentionally omits range validation to support * legacy content that may exceed this limit in _count fields or other * integer values. Real-world content often violates the spec by storing * larger values, and strict enforcement would break compatibility. * * Affected elements: * - cmi.objectives._count * - cmi.interactions._count * - cmi.interactions.n.objectives._count * - cmi.interactions.n.correct_responses._count */ CMIInteger: "^\\d+$", /** CMISInteger - Signed integer (RTE A.5) */ CMISInteger: "^-?([0-9]+)$", /** * CMIDecimal - Signed decimal (RTE A.6) * We set practical limits on decimals to prevent abuse while maintaining * broad compatibility with legacy content. * Increased from 3 to 10 digits before decimal to match SCORM 2004 behavior. */ CMIDecimal: "^-?([0-9]{0,10})(\\.[0-9]*)?$", /** * CMIIdentifier - Printable ASCII characters, max 255 chars (RTE A.7) * * SPEC COMPLIANCE NOTE: * The SCORM 1.2 specification defines CMIIdentifier as alphanumeric only: * letters (a-z, A-Z), numbers (0-9), hyphens (-), and underscores (_). * Spaces and periods are explicitly NOT allowed per spec. * * This implementation intentionally relaxes validation to accept all * printable ASCII characters (0x21-0x7E) plus whitespace to support * legacy content. Many real-world LMS systems and content packages use * identifiers that violate the strict spec (e.g., student IDs with spaces, * objective IDs with periods or special characters). * * Strict spec pattern would be: ^[A-Za-z0-9_-]{0,255}$ * * Affected elements: * - cmi.core.student_id * - cmi.objectives.n.id * - cmi.interactions.n.id * - cmi.interactions.n.objectives.n.id */ CMIIdentifier: "^[\\u0021-\\u007E\\s]{0,255}$", /** CMICredit - Vocabulary: credit or no-credit (RTE 3.4.2.1.3) */ CMICredit: "^(credit|no-credit)$", /** CMIEntry - Vocabulary: ab-initio, resume, or empty (RTE 3.4.2.1.4) */ CMIEntry: "^(ab-initio|resume|)$", /** CMILessonMode - Vocabulary: normal, browse, or review (RTE 3.4.2.1.10) */ CMILessonMode: "^(normal|browse|review)$", /** CMITimeLimitAction - Vocabulary: action combinations (RTE 3.4.2.1.11) */ CMITimeLimitAction: "^(exit,message|exit,no message|continue,message|continue,no message)$", /** * CMIFeedback - Relaxed for compatibility (normally CMIString255) * * SPEC COMPLIANCE NOTE: * The SCORM 1.2 specification defines CMIFeedback as CMIString255 (max 255 chars) * with format varying by interaction type (see RTE 3.4.2.7.5, 3.4.2.7.7). * * This implementation intentionally relaxes validation for two reasons: * * 1. LENGTH: Many legacy content packages store responses exceeding 255 chars, * especially for fill-in and performance interaction types. Strict enforcement * would break existing content with no user-facing benefit. * * 2. FORMAT: The spec requires type-specific formats (e.g., true-false accepts * only "0"/"1"/"t"/"f", choice accepts comma-separated single chars). However: * - Format validation requires knowing interaction type at validation time * - Legacy content often uses non-standard formats * - The SCO is responsible for response evaluation, not the API * - Strict format validation provides minimal benefit vs. compatibility cost * * Affected elements: * - cmi.interactions.n.student_response * - cmi.interactions.n.correct_responses.n.pattern * * Strict spec pattern would be: ^[\s\S]{0,255}$ with type-specific subpatterns */ CMIFeedback: "^.*$", /** CMIIndex - Pattern for array index extraction */ CMIIndex: "[._](\\d+).", /** CMIStatus - Lesson status vocabulary (RTE 3.4.2.2.3) */ CMIStatus: "^(passed|completed|failed|incomplete|browsed)$", /** CMIStatus2 - Extended status vocabulary with "not attempted" (RTE 3.4.2.6.2) */ CMIStatus2: "^(passed|completed|failed|incomplete|browsed|not attempted)$", /** CMIExit - Exit vocabulary (RTE 3.4.2.1.5) */ CMIExit: "^(time-out|suspend|logout|)$", /** CMIType - Interaction type vocabulary (RTE 3.4.2.7.2) */ CMIType: "^(true-false|choice|fill-in|matching|performance|sequencing|likert|numeric)$", /** CMIResult - Interaction result vocabulary (RTE 3.4.2.7.6) */ CMIResult: "^(correct|wrong|unanticipated|neutral|([0-9]{0,3})?(\\.[0-9]*)?)$", /** NAVEvent - Navigation event vocabulary (SCORM 1.2 extension) */ NAVEvent: "^(_?(previous|continue|start|resumeAll|exit|exitAll|abandon|abandonAll|suspendAll|retry|retryAll)|choice|jump|_none_)$", /** score_range - Valid score range 0-100 (RTE 3.4.2.2.2) */ score_range: "0#100", /** audio_range - Audio level range -1 to 100 (RTE 3.4.2.3.1) */ audio_range: "-1#100", /** speed_range - Playback speed range -100 to 100 (RTE 3.4.2.3.2) */ speed_range: "-100#100", /** weighting_range - Interaction weighting range -100 to 100 (RTE 3.4.2.7.4) */ weighting_range: "-100#100", /** text_range - Text display preference -1 to 1 (RTE 3.4.2.3.3) */ text_range: "-1#1" }; const scorm2004_regex = { /** CMIString200 - Character string, max 200 chars (RTE C.1.1) */ CMIString200: "^[\\u0000-\\uFFFF]{0,200}$", /** CMIString250 - Character string, max 250 chars (RTE C.1.1) */ CMIString250: "^[\\u0000-\\uFFFF]{0,250}$", /** CMIString1000 - Character string, max 1000 chars (RTE C.1.1) */ CMIString1000: "^[\\u0000-\\uFFFF]{0,1000}$", /** CMIString4000 - Character string, max 4000 chars (RTE C.1.1) */ CMIString4000: "^[\\u0000-\\uFFFF]{0,4000}$", /** CMIString64000 - Character string, max 64000 chars (RTE C.1.1) */ CMIString64000: "^[\\u0000-\\uFFFF]{0,64000}$", /** * CMILang - Language code per RFC 1766/RFC 3066 (RTE C.1.2) * Primary tag: 1-8 characters (ISO 639-1: 2, ISO 639-2: 3, or i/x for IANA/private) * Subtag: 2-8 alphanumeric characters */ CMILang: "^([a-zA-Z]{1,8}|i|x)(-[a-zA-Z0-9-]{2,8})?$|^$", /** CMILangString250 - String with optional language tag, max 250 chars (RTE C.1.3) */ CMILangString250: "^({lang=([a-zA-Z]{1,8}|i|x)(-[a-zA-Z0-9-]{2,8})?})?((?!{.*$).{0,250}$)?$", /** CMILangcr - Language tag pattern with content */ CMILangcr: "^(({lang=([a-zA-Z]{1,8}|i|x)?(-[a-zA-Z0-9-]{2,8})?}))(.*?)$", /** CMILangString250cr - String with optional language tag (carriage return variant) */ CMILangString250cr: "^(({lang=([a-zA-Z]{1,8}|i|x)?(-[a-zA-Z0-9-]{2,8})?})?(.{0,250})?)?$", /** CMILangString4000 - String with optional language tag, max 4000 chars (RTE C.1.3) */ CMILangString4000: "^({lang=([a-zA-Z]{1,8}|i|x)(-[a-zA-Z0-9-]{2,8})?})?((?!{.*$).{0,4000}$)?$", /** * CMITime - ISO 8601 timestamp format (RTE C.1.4) * Year range expanded from 1970-2038 to 1970-9999 to support future dates */ CMITime: "^(19[7-9][0-9]|[2-9][0-9]{3})((-(0[1-9]|1[0-2]))((-(0[1-9]|[1-2][0-9]|3[0-1]))(T([0-1][0-9]|2[0-3])((:[0-5][0-9])((:[0-5][0-9])((\\.[0-9]{1,6})((Z|([+|-]([0-1][0-9]|2[0-3])))(:[0-5][0-9])?)?)?)?)?)?)?)?$", /** CMITimespan - ISO 8601 duration format (RTE C.1.5) */ CMITimespan: "^P(?:([.,\\d]+)Y)?(?:([.,\\d]+)M)?(?:([.,\\d]+)W)?(?:([.,\\d]+)D)?(?:T?(?:([.,\\d]+)H)?(?:([.,\\d]+)M)?(?:(\\d+(?:\\.\\d{1,2})?)S)?)?$", /** CMIInteger - Non-negative integer (RTE C.1.6) */ CMIInteger: "^\\d+$", /** CMISInteger - Signed integer (RTE C.1.7) */ CMISInteger: "^-?([0-9]+)$", /** * CMIDecimal - Signed decimal (RTE C.1.8) * Spec allows unlimited digits, but we set practical limits to prevent abuse * while maintaining broad compatibility: * - Up to 10 digits before decimal (supports values up to 10 billion) * - Up to 18 digits after decimal (maintains precision for scientific use) */ CMIDecimal: "^-?([0-9]{1,10})(\\.[0-9]{1,18})?$", /** * CMIIdentifier - Identifier with alphanumeric ending, max 250 chars (RTE C.1.9) * Must contain at least one word character (\w) and only allow: letters, * numbers, - ( ) + . : = @ ; $ _ ! * ' % / # * URN format is validated separately if string starts with "urn:" */ CMIIdentifier: "^(?=.*\\w)[\\w\\-\\(\\)\\+\\.\\:\\=\\@\\;\\$\\_\\!\\*\\'\\%\\/\\#]{1,250}$", /** CMIShortIdentifier - Short identifier conforming to URI syntax, max 250 chars (RTE C.1.10) */ CMIShortIdentifier: "^(?=.*\\w)[\\w\\-\\(\\)\\+\\.\\:\\=\\@\\;\\$\\_\\!\\*\\'\\%\\/\\#]{1,250}$", /** CMILongIdentifier - Long identifier supporting URN format, max 4000 chars (RTE C.1.11) */ CMILongIdentifier: "^(?:(?!urn:)\\S{1,4000}|urn:[A-Za-z0-9-]{1,31}:\\S{1,4000}|.{1,4000})$", /** CMIFeedback - Unrestricted feedback text (RTE C.1.12) */ CMIFeedback: "^.*$", /** CMIIndex - Pattern for array index extraction */ CMIIndex: "[._](\\d+).", /** CMIIndexStore - Pattern for stored index notation */ CMIIndexStore: ".N(\\d+).", /** CMICStatus - Completion status vocabulary (RTE 4.1.4) */ CMICStatus: "^(completed|incomplete|not attempted|unknown)$", /** CMISStatus - Success status vocabulary (RTE 4.1.11) */ CMISStatus: "^(passed|failed|unknown)$", /** CMIExit - Exit vocabulary (RTE 4.1.3) */ CMIExit: "^(time-out|suspend|logout|normal)$", /** CMIType - Interaction type vocabulary (RTE 4.1.6.2) */ CMIType: "^(true-false|choice|fill-in|long-fill-in|matching|performance|sequencing|likert|numeric|other)$", /** CMIResult - Interaction result vocabulary (RTE 4.1.6.8) */ CMIResult: "^(correct|incorrect|unanticipated|neutral|-?([0-9]{1,4})(\\.[0-9]{1,18})?)$", /** NAVEvent - Navigation event vocabulary (SN Book Table 4.4.2) */ NAVEvent: "^(_?(start|resumeAll|previous|continue|exit|exitAll|abandon|abandonAll|suspendAll|retry|retryAll)|_none_|(\\{target=(?<choice_target>\\S{0,}[a-zA-Z0-9-_]+)})?choice|(\\{target=(?<jump_target>\\S{0,}[a-zA-Z0-9-_]+)})?jump)$", /** NAVBoolean - Navigation boolean vocabulary (SN Book) */ NAVBoolean: "^(unknown|true|false)$", /** NAVTarget - Navigation target pattern (SN Book) */ NAVTarget: "^{target=\\S{0,}[a-zA-Z0-9-_]+}$", /** scaled_range - Scaled score range -1 to 1 (RTE 4.1.10.1) */ scaled_range: "-1#1", /** audio_range - Audio level range 0 to 999.9999999 (RTE 4.1.7.1) */ audio_range: "0#999.9999999", /** speed_range - Playback speed range 0 to 999.9999999 (RTE 4.1.7.4) */ speed_range: "0#999.9999999", /** text_range - Text display preference -1 to 1 (RTE 4.1.7.5) */ text_range: "-1#1", /** progress_range - Progress measure range 0 to 1 (RTE 4.1.8) */ progress_range: "0#1" }; class ScheduledCommit { _API; _cancelled = false; _timeout; _callback; /** * Constructor for ScheduledCommit * @param {BaseAPI} API * @param {number} when * @param {string} callback */ constructor(API, when, callback) { 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 canceled */ wrapper() { if (!this._cancelled) { if (this._API.isInitialized()) { (async () => await this._API.commit(this._callback))(); } } } } class AsynchronousHttpService { settings; error_codes; /** * Constructor for AsynchronousHttpService * @param {Settings} settings - The settings object * @param {ErrorCode} error_codes - The error codes object */ constructor(settings, error_codes) { this.settings = settings; this.error_codes = error_codes; } /** * Sends HTTP requests asynchronously to the LMS * Returns immediate success - actual result handled via events * * WARNING: This is NOT SCORM-compliant. Always returns optimistic success immediately. * The actual HTTP request happens in the background, and success/failure is reported * via CommitSuccess/CommitError events, but NOT to the SCO's commit call. * * @param {string} url - The URL endpoint to send the request to * @param {CommitObject|StringKeyMap|Array} params - The data to send to the LMS * @param {boolean} immediate - Whether to send the request immediately without waiting * @param {Function} apiLog - Function to log API messages with appropriate levels * @param {Function} processListeners - Function to trigger event listeners for commit events * @return {ResultObject} - Immediate optimistic success result */ processHttpRequest(url, params, immediate = false, apiLog, processListeners) { this._performAsyncRequest(url, params, immediate, apiLog, processListeners); return { result: global_constants.SCORM_TRUE, errorCode: 0 }; } /** * Performs the async request in the background * @param {string} url - The URL to send the request to * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request * @param {boolean} immediate - Whether this is an immediate request * @param apiLog - Function to log API messages * @param {Function} processListeners - Function to process event listeners * @private */ async _performAsyncRequest(url, params, immediate, apiLog, processListeners) { try { const processedParams = this.settings.requestHandler(params); let response; if (immediate && this.settings.asyncModeBeaconBehavior !== "never") { response = await this.performBeacon(url, processedParams); } else { response = await this.performFetch(url, processedParams); } const result = await this.transformResponse(response, processListeners); if (this._isSuccessResponse(response, result)) { processListeners("CommitSuccess"); } else { processListeners("CommitError", void 0, result.errorCode); } } catch (e) { const message = e instanceof Error ? e.message : String(e); apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR); processListeners("CommitError"); } } /** * Prepares the request body and content type based on params type * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request * @return {Object} - Object containing body and contentType * @private */ _prepareRequestBody(params) { const body = params instanceof Array ? params.join("&") : JSON.stringify(params); const contentType = params instanceof Array ? "application/x-www-form-urlencoded" : this.settings.commitRequestDataType; return { body, contentType }; } /** * Perform the fetch request to the LMS * @param {string} url - The URL to send the request to * @param {StringKeyMap|Array} params - The parameters to include in the request * @return {Promise<Response>} - The response from the LMS * @private */ async performFetch(url, params) { if (this.settings.asyncModeBeaconBehavior === "always") { return this.performBeacon(url, params); } const { body, contentType } = this._prepareRequestBody(params); const init = { method: "POST", mode: this.settings.fetchMode, body, headers: { ...this.settings.xhrHeaders, "Content-Type": contentType }, keepalive: true }; if (this.settings.xhrWithCredentials) { init.credentials = "include"; } return fetch(url, init); } /** * Perform the beacon request to the LMS * @param {string} url - The URL to send the request to * @param {StringKeyMap|Array} params - The parameters to include in the request * @return {Promise<Response>} - A promise that resolves with a mock Response object * @private */ async performBeacon(url, params) { const { body, contentType } = this._prepareRequestBody(params); const beaconSuccess = navigator.sendBeacon(url, new Blob([body], { type: contentType })); return Promise.resolve({ status: beaconSuccess ? 200 : 0, ok: beaconSuccess, json: async () => ({ result: beaconSuccess ? "true" : "false", errorCode: beaconSuccess ? 0 : this.error_codes.GENERAL_COMMIT_FAILURE || 391 }), text: async () => JSON.stringify({ result: beaconSuccess ? "true" : "false", errorCode: beaconSuccess ? 0 : this.error_codes.GENERAL_COMMIT_FAILURE || 391 }) }); } /** * Transforms the response from the LMS to a ResultObject * @param {Response} response - The response from the LMS * @param {Function} processListeners - Function to process event listeners * @return {Promise<ResultObject>} - The transformed response * @private */ async transformResponse(response, processListeners) { let result; try { result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json(); } catch (parseError) { const responseText = await response.text().catch(() => "Unable to read response text"); return { result: global_constants.SCORM_FALSE, errorCode: this.error_codes.GENERAL_COMMIT_FAILURE || 391, errorMessage: `Failed to parse LMS response: ${parseError instanceof Error ? parseError.message : String(parseError)}`, errorDetails: JSON.stringify({ status: response.status, statusText: response.statusText, url: response.url, responseText: responseText.substring(0, 500), // Limit response text to avoid huge logs parseError: parseError instanceof Error ? parseError.message : String(parseError) }) }; } if (!Object.hasOwnProperty.call(result, "errorCode")) { result.errorCode = this._isSuccessResponse(response, result) ? 0 : this.error_codes.GENERAL_COMMIT_FAILURE || 391; } if (!this._isSuccessResponse(response, result)) { result.errorDetails = { status: response.status, statusText: response.statusText, url: response.url, ...result.errorDetails // Preserve any existing error details }; } return result; } /** * Determines if a response is successful based on status code and result * @param {Response} response - The HTTP response * @param {ResultObject} result - The parsed result object * @return {boolean} - Whether the response is successful * @private */ _isSuccessResponse(response, result) { const value = result.result; return response.status >= 200 && response.status <= 299 && (value === true || value === "true" || value === global_constants.SCORM_TRUE); } /** * Updates the service settings * @param {Settings} settings - The new settings */ updateSettings(settings) { this.settings = settings; } } const TARGET_ATTRIBUTE_PREFIX = "{target="; function getErrorCode(errorCodes, key) { const code = errorCodes[key]; if (code === void 0) { if (typeof console !== "undefined" && console.warn) { console.warn(`CMIValueAccessService: Unknown error code key: ${key}`); } return errorCodes["GENERAL"] ?? 0; } return code; } class CMIValueAccessService { context; constructor(context) { this.context = context; } /** * Gets the appropriate error code for undefined data model elements. * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL. */ getUndefinedDataModelErrorCode(scorm2004) { return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL"); } /** * Sets a value on a CMI element path * * @param {string} methodName - The API method name for logging * @param {boolean} scorm2004 - Whether this is SCORM 2004 * @param {string} CMIElement - The CMI element path * @param {string} value - The value to set (all SCORM values are strings) * @return {string} "true" or "false" */ setCMIValue(methodName, scorm2004, CMIElement, value) { if (!CMIElement || CMIElement === "") { if (scorm2004) { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "GENERAL_SET_FAILURE"), "The data model element was not specified" ); } return global_constants.SCORM_FALSE; } this.context.setLastErrorCode("0"); const structure = CMIElement.split("."); let refObject = this.context.getDataModel(); 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 = this.getUndefinedDataModelErrorCode(scorm2004); for (let idx = 0; idx < structure.length; idx++) { const attribute = structure[idx]; if (idx === structure.length - 1) { returnValue = this.setFinalAttribute( refObject, attribute, value, CMIElement, scorm2004, invalidErrorCode, invalidErrorMessage ); break; } else { const traverseResult = this.traverseToNextLevel( refObject, structure, idx, value, CMIElement, scorm2004, foundFirstIndex, invalidErrorCode, invalidErrorMessage ); if (traverseResult.error) { break; } refObject = traverseResult.refObject; idx = traverseResult.idx; foundFirstIndex = traverseResult.foundFirstIndex; } } if (returnValue === global_constants.SCORM_FALSE) { this.context.apiLog( methodName, `There was an error setting the value for: ${CMIElement}, value of: ${value}`, LogLevelEnum.WARN ); } return returnValue; } /** * Gets a value from a CMI element path * * @param {string} methodName - The API method name for logging * @param {boolean} scorm2004 - Whether this is SCORM 2004 * @param {string} CMIElement - The CMI element path * @return The value at the element path, or empty string on error (per SCORM spec) */ getCMIValue(methodName, scorm2004, CMIElement) { if (!CMIElement || CMIElement === "") { if (scorm2004) { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "GENERAL_GET_FAILURE"), "The data model element was not specified" ); } return ""; } if (scorm2004 && CMIElement.endsWith("._version") && CMIElement !== "cmi._version") { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "GENERAL_GET_FAILURE"), "The _version keyword was used incorrectly" ); return ""; } const structure = CMIElement.split("."); let refObject = this.context.getDataModel(); 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 = this.getUndefinedDataModelErrorCode(scorm2004); for (let idx = 0; idx < structure.length; idx++) { attribute = structure[idx]; const validationResult = this.validateGetAttribute( refObject, attribute, CMIElement, scorm2004, invalidErrorCode, invalidErrorMessage, idx === structure.length - 1 ); if (validationResult.returnValue !== void 0) { return validationResult.returnValue; } if (validationResult.error) { return ""; } if (attribute !== void 0 && attribute !== null) { refObject = refObject[attribute]; if (refObject === void 0) { this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage); break; } } else { this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage); break; } if (refObject instanceof CMIArray) { const arrayResult = this.handleGetArrayAccess( refObject, structure, idx, CMIElement, uninitializedErrorMessage ); if (arrayResult.error) { return ""; } refObject = arrayResult.refObject; idx = arrayResult.idx; } } if (refObject === null || refObject === void 0) { if (!scorm2004) { if (attribute === "_children") { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "CHILDREN_ERROR"), void 0 ); } else if (attribute === "_count") { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "COUNT_ERROR"), void 0 ); } } return ""; } return refObject; } /** * Sets the final attribute value in the CMI path */ setFinalAttribute(refObject, attribute, value, CMIElement, scorm2004, invalidErrorCode, invalidErrorMessage) { if (scorm2004 && attribute?.startsWith(TARGET_ATTRIBUTE_PREFIX)) { if (this.context.isInitialized()) { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "READ_ONLY_ELEMENT") ); return global_constants.SCORM_FALSE; } return global_constants.SCORM_TRUE; } if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) { this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage); return global_constants.SCORM_FALSE; } if (stringMatches(CMIElement, "\\.correct_responses\\.\\d+$") && this.context.isInitialized() && attribute !== "pattern") { this.context.validateCorrectResponse(CMIElement, value); if (this.context.getLastErrorCode() !== "0") { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "TYPE_MISMATCH") ); return global_constants.SCORM_FALSE; } } if (!scorm2004 || this.context.getLastErrorCode() === "0") { if (typeof attribute === "undefined" || attribute === "__proto__" || attribute === "constructor") { this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage); return global_constants.SCORM_FALSE; } if (scorm2004 && attribute === "id" && this.context.isInitialized()) { const duplicateError = this.context.checkForDuplicateId(CMIElement, value); if (duplicateError) { this.context.throwSCORMError( CMIElement, getErrorCode(this.context.errorCodes, "GENERAL_SET_FAILURE") ); return global_constants.SCORM_FALSE; } } refObject[attribute] = value; return global_constants.SCORM_TRUE; } return global_constants.SCORM_FALSE; } /** * Traverses to the next level in the CMI path */ traverseToNextLevel(refObject, structure, idx, value, CMIElement, scorm2004, foundFirstIndex, invalidErrorCode, invalidErrorMessage) { const attribute = structure[idx]; if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) { this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage); return { refObject, idx, foundFirstIndex, error: true }; } refObject = refObject[attribute]; if (!refObject) { this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage); return { refObject, idx, foundFirstIndex, error: true }; } if (refObject instanceof CMIArray) { const arrayResult = this.handleSetArrayAccess( refObject, structure, idx, value, CMIElement, scorm2004, foundFirstIndex, invalidErrorCode, invalidErrorMessage ); if (arrayResult.error) { return { refObject, idx, foundFirstIndex, error: true }; } return arrayResult; } return { refObject, idx, foundFirstIndex, error: false }; } /** * Handles array access during set operations */ handleSetArrayAccess(refObject, structure, idx, value, CMIElement, scorm2004, foundFirstIndex, invalidErrorCode, invalidErrorMessage) { const index = parseInt(structure[idx + 1] || "0", 10); if (!isNaN(index)) { const item = refObject.childArray[index]; if (item) { return { refObject: item, idx: idx + 1, foundFirstIndex: true,