scorm-again
Version:
A modern SCORM JavaScript run-time library for SCORM 1.2 and SCORM 2004
1,314 lines (1,298 loc) • 254 kB
JavaScript
this.Scorm12API = (function () {
'use strict';
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 function () {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
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;
})();
};
}
var __defProp$m = Object.defineProperty;
var __defNormalProp$m = (obj, key, value) => key in obj ? __defProp$m(obj, key, {
enumerable: true,
configurable: true,
writable: true,
value
}) : obj[key] = value;
var __publicField$m = (obj, key, value) => __defNormalProp$m(obj, typeof key !== "symbol" ? key + "" : key, value);
class BaseCMI {
/**
* Constructor for BaseCMI
* @param {string} cmi_element
*/
constructor(cmi_element) {
/**
* 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.
*/
__publicField$m(this, "jsonString", false);
__publicField$m(this, "_cmi_element");
__publicField$m(this, "_initialized", false);
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 {
constructor() {
super(...arguments);
__publicField$m(this, "_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.");
}
}
}
var __defProp$l = Object.defineProperty;
var __defNormalProp$l = (obj, key, value) => key in obj ? __defProp$l(obj, key, {
enumerable: true,
configurable: true,
writable: true,
value
}) : obj[key] = value;
var __publicField$l = (obj, key, value) => __defNormalProp$l(obj, typeof key !== "symbol" ? key + "" : key, value);
class BaseScormValidationError extends Error {
constructor(CMIElement, errorCode) {
super(`${CMIElement} : ${errorCode.toString()}`);
__publicField$l(this, "_errorCode");
this._errorCode = errorCode;
Object.setPrototypeOf(this, BaseScormValidationError.prototype);
}
/**
* 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);
__publicField$l(this, "_errorMessage");
__publicField$l(this, "_detailedMessage", "");
this.message = `${CMIElement} : ${errorMessage}`;
this._errorMessage = errorMessage;
if (detailedMessage) {
this._detailedMessage = detailedMessage;
}
Object.setPrototypeOf(this, ValidationError.prototype);
}
/**
* 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
};
var __defProp$k = Object.defineProperty;
var __defNormalProp$k = (obj, key, value) => key in obj ? __defProp$k(obj, key, {
enumerable: true,
configurable: true,
writable: true,
value
}) : obj[key] = value;
var __publicField$k = (obj, key, value) => __defNormalProp$k(obj, typeof key !== "symbol" ? key + "" : key, value);
class CMIArray extends BaseCMI {
/**
* Constructor cmi *.n arrays
* @param {object} params
*/
constructor(params) {
super(params.CMIElement);
__publicField$k(this, "_errorCode");
__publicField$k(this, "_errorClass");
__publicField$k(this, "__children");
__publicField$k(this, "childArray");
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() {
let wipe = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 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"
};
var __defProp$j = Object.defineProperty;
var __defNormalProp$j = (obj, key, value) => key in obj ? __defProp$j(obj, key, {
enumerable: true,
configurable: true,
writable: true,
value
}) : obj[key] = value;
var __publicField$j = (obj, key, value) => __defNormalProp$j(obj, typeof key !== "symbol" ? key + "" : key, value);
class ScheduledCommit {
/**
* Constructor for ScheduledCommit
* @param {BaseAPI} API
* @param {number} when
* @param {string} callback
*/
constructor(API, when, callback) {
__publicField$j(this, "_API");
__publicField$j(this, "_cancelled", false);
__publicField$j(this, "_timeout");
__publicField$j(this, "_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))();
}
}
}
}
const HIDE_LMS_UI_TOKENS = ["continue", "previous", "exit", "exitAll", "abandon", "abandonAll", "suspendAll"];
var __defProp$i = Object.defineProperty;
var __defNormalProp$i = (obj, key, value) => key in obj ? __defProp$i(obj, key, {
enumerable: true,
configurable: true,
writable: true,
value
}) : obj[key] = value;
var __publicField$i = (obj, key, value) => __defNormalProp$i(obj, typeof key !== "symbol" ? key + "" : key, value);
const _RuleCondition = class _RuleCondition extends BaseCMI {
/**
* Constructor for RuleCondition
* @param {RuleConditionType} condition - The condition type
* @param {RuleConditionOperator | null} operator - The operator (null for no operator)
* @param {Map<string, any>} parameters - Additional parameters for the condition
*/
constructor() {
let condition = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "always";
let operator = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
let parameters = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : /* @__PURE__ */new Map();
super("ruleCondition");
__publicField$i(this, "_condition", "always" /* ALWAYS */);
__publicField$i(this, "_operator", null);
__publicField$i(this, "_parameters", /* @__PURE__ */new Map());
__publicField$i(this, "_referencedObjective", null);
this._condition = condition;
this._operator = operator;
this._parameters = parameters;
}
/**
* Allow integrators to override the clock used for time-based rules.
*/
static setNowProvider(now) {
if (typeof now === "function") {
_RuleCondition._now = now;
}
}
/**
* Allow integrators to set an elapsed seconds hook for time limit calculations
*/
static setElapsedSecondsHook(hook) {
_RuleCondition._getElapsedSecondsHook = hook;
}
/**
* Called when the API needs to be reset
*/
reset() {
this._initialized = false;
this._condition = "always" /* ALWAYS */;
this._operator = null;
this._parameters = /* @__PURE__ */new Map();
}
/**
* Getter for condition
* @return {RuleConditionType}
*/
get condition() {
return this._condition;
}
/**
* Setter for condition
* @param {RuleConditionType} condition
*/
set condition(condition) {
this._condition = condition;
}
/**
* Getter for operator
* @return {RuleConditionOperator | null}
*/
get operator() {
return this._operator;
}
/**
* Setter for operator
* @param {RuleConditionOperator | null} operator
*/
set operator(operator) {
this._operator = operator;
}
/**
* Getter for parameters
* @return {Map<string, any>}
*/
get parameters() {
return this._parameters;
}
/**
* Setter for parameters
* @param {Map<string, any>} parameters
*/
set parameters(parameters) {
this._parameters = parameters;
}
get referencedObjective() {
return this._referencedObjective;
}
set referencedObjective(objectiveId) {
this._referencedObjective = objectiveId;
}
resolveReferencedObjective(activity) {
if (!this._referencedObjective) {
return null;
}
if (activity.primaryObjective?.id === this._referencedObjective) {
return activity.primaryObjective;
}
const objectives = activity.objectives || [];
return objectives.find(obj => obj.id === this._referencedObjective) || null;
}
/**
* Evaluate the condition for an activity
* @param {Activity} activity - The activity to evaluate the condition for
* @return {boolean} - True if the condition is met, false otherwise
*/
evaluate(activity) {
let result;
const referencedObjective = this.resolveReferencedObjective(activity);
switch (this._condition) {
case "satisfied" /* SATISFIED */:
case "objectiveSatisfied" /* OBJECTIVE_SATISFIED */:
if (referencedObjective) {
result = referencedObjective.satisfiedStatus === true;
} else {
result = activity.successStatus === SuccessStatus.PASSED || activity.objectiveSatisfiedStatus === true;
}
break;
case "objectiveStatusKnown" /* OBJECTIVE_STATUS_KNOWN */:
result = referencedObjective ? !!referencedObjective.measureStatus : !!activity.objectiveMeasureStatus;
break;
case "objectiveMeasureKnown" /* OBJECTIVE_MEASURE_KNOWN */:
result = referencedObjective ? !!referencedObjective.measureStatus : !!activity.objectiveMeasureStatus;
break;
case "objectiveMeasureGreaterThan" /* OBJECTIVE_MEASURE_GREATER_THAN */:
{
const greaterThanValue = this._parameters.get("threshold") || 0;
const measureStatus = referencedObjective ? referencedObjective.measureStatus : activity.objectiveMeasureStatus;
const measureValue = referencedObjective ? referencedObjective.normalizedMeasure : activity.objectiveNormalizedMeasure;
result = !!measureStatus && measureValue > greaterThanValue;
break;
}
case "objectiveMeasureLessThan" /* OBJECTIVE_MEASURE_LESS_THAN */:
{
const lessThanValue = this._parameters.get("threshold") || 0;
const measureStatus = referencedObjective ? referencedObjective.measureStatus : activity.objectiveMeasureStatus;
const measureValue = referencedObjective ? referencedObjective.normalizedMeasure : activity.objectiveNormalizedMeasure;
result = !!measureStatus && measureValue < lessThanValue;
break;
}
case "completed" /* COMPLETED */:
case "activityCompleted" /* ACTIVITY_COMPLETED */:
if (referencedObjective) {
result = referencedObjective.completionStatus === CompletionStatus.COMPLETED;
} else {
result = activity.isCompleted;
}
break;
case "progressKnown" /* PROGRESS_KNOWN */:
case "activityProgressKnown" /* ACTIVITY_PROGRESS_KNOWN */:
if (referencedObjective) {
result = referencedObjective.completionStatus !== CompletionStatus.UNKNOWN;
} else {
result = activity.completionStatus !== "unknown";
}
break;
case "attempted" /* ATTEMPTED */:
result = activity.attemptCount > 0;
break;
case "attemptLimitExceeded" /* ATTEMPT_LIMIT_EXCEEDED */:
result = activity.hasAttemptLimitExceeded();
break;
case "timeLimitExceeded" /* TIME_LIMIT_EXCEEDED */:
result = this.evaluateTimeLimitExceeded(activity);
break;
case "outsideAvailableTimeRange" /* OUTSIDE_AVAILABLE_TIME_RANGE */:
result = this.evaluateOutsideAvailableTimeRange(activity);
break;
case "always" /* ALWAYS */:
result = true;
break;
case "never" /* NEVER */:
result = false;
break;
default:
result = false;
break;
}
if (this._operator === "not" /* NOT */) {
result = !result;
}
return result;
}
/**
* Evaluate if time limit has been exceeded
* @param {Activity} activity - The activity to evaluate
* @return {boolean}
* @private
*/
evaluateTimeLimitExceeded(activity) {
let limit = activity.timeLimitDuration;
if (!limit && activity.attemptAbsoluteDurationLimit) {
limit = activity.attemptAbsoluteDurationLimit;
}
if (!limit) {
return false;
}
const limitSeconds = getDurationAsSeconds(limit, scorm2004_regex.CMITimespan);
if (limitSeconds <= 0) {
return false;
}
let elapsedSeconds = 0;
if (_RuleCondition._getElapsedSecondsHook) {
try {
const hookResult = _RuleCondition._getElapsedSecondsHook(activity);
if (typeof hookResult === "number" && !Number.isNaN(hookResult) && hookResult >= 0) {
elapsedSeconds = hookResult;
}
} catch {
elapsedSeconds = 0;
}
}
if (elapsedSeconds === 0 && activity.attemptExperiencedDuration) {
const attemptDurationSeconds = getDurationAsSeconds(activity.attemptExperiencedDuration, scorm2004_regex.CMITimespan);
if (attemptDurationSeconds > 0) {
elapsedSeconds = attemptDurationSeconds;
}
}
if (elapsedSeconds === 0 && activity.attemptAbsoluteStartTime) {
try {
const start = new Date(activity.attemptAbsoluteStartTime).getTime();
const nowMs = _RuleCondition._now().getTime();
if (!Number.isNaN(start) && !Number.isNaN(nowMs) && nowMs >= start) {
elapsedSeconds = (nowMs - start) / 1e3;
}
} catch {
elapsedSeconds = 0;
}
}
return elapsedSeconds > limitSeconds;
}
/**
* Evaluate if activity is outside available time range
* @param {Activity} activity - The activity to evaluate
* @return {boolean}
* @private
*/
evaluateOutsideAvailableTimeRange(activity) {
const beginTime = activity.beginTimeLimit;
const endTime = activity.endTimeLimit;
if (!beginTime && !endTime) {
return false;
}
const now = _RuleCondition._now();
if (beginTime) {
const beginDate = new Date(beginTime);
if (now < beginDate) {
return true;
}
}
if (endTime) {
const endDate = new Date(endTime);
if (now > endDate) {
return true;
}
}
return false;
}
/**
* Parse ISO 8601 duration to milliseconds
* Uses the standard getDurationAsSeconds utility which supports full ISO 8601 format
* including date components (years, months, weeks, days) and time components (hours, minutes, seconds).
* @param {string} duration - ISO 8601 duration string (e.g., "PT1H30M", "P1D", "P1Y2M3DT4H5M6S")
* @return {number} - Duration in milliseconds
* @private
*/
parseISO8601Duration(duration) {
const seconds = getDurationAsSeconds(duration, scorm2004_regex.CMITimespan);
return seconds * 1e3;
}
/**
* toJSON for RuleCondition
* @return {object}
*/
toJSON() {
this.jsonString = true;
const result = {
condition: this._condition,
operator: this._operator,
parameters: Object.fromEntries(this._parameters)
};
this.jsonString = false;
return result;
}
};
// Optional, overridable provider for current time (LMS may set via SequencingService)
__publicField$i(_RuleCondition, "_now", () => /* @__PURE__ */new Date());
// Optional, overridable hook for getting elapsed seconds
__publicField$i(_RuleCondition, "_getElapsedSecondsHook");
var SelectionTiming = /* @__PURE__ */(SelectionTiming2 => {
SelectionTiming2["NEVER"] = "never";
SelectionTiming2["ONCE"] = "once";
SelectionTiming2["ON_EACH_NEW_ATTEMPT"] = "onEachNewAttempt";
return SelectionTiming2;
})(SelectionTiming || {});
var RandomizationTiming = /* @__PURE__ */(RandomizationTiming2 => {
RandomizationTiming2["NEVER"] = "never";
RandomizationTiming2["ONCE"] = "once";
RandomizationTiming2["ON_EACH_NEW_ATTEMPT"] = "onEachNewAttempt";
return RandomizationTiming2;
})(RandomizationTiming || {});
class SelectionRandomization {
/**
* Select Children Process (SR.1)
* Selects a subset of child activities based on selection controls
* @param {Activity} activity - The parent activity whose children need to be selected
* @return {Activity[]} - The selected child activities
*/
static selectChildrenProcess(activity) {
const controls = activity.sequencingControls;
const children = [...activity.children];
if (controls.selectionTiming === SelectionTiming.NEVER) {
return children;
}
if (controls.selectionTiming === SelectionTiming.ONCE && controls.selectionCountStatus) {
return children;
}
if (controls.selectionTiming !== SelectionTiming.ONCE && !controls.selectionCountStatus) {
return children;
}
const selectCount = controls.selectCount;
if (selectCount === null || selectCount >= children.length) {
if (controls.selectionTiming === SelectionTiming.ONCE) {
controls.selectionCountStatus = true;
}
return children;
}
const selectedChildren = [];
const availableIndices = children.map((_, index) => index);
for (let i = 0; i < selectCount; i++) {
if (availableIndices.length === 0) break;
const randomIndex = Math.floor(Math.random() * availableIndices.length);
const childIndex = availableIndices[randomIndex];
if (childIndex !== void 0 && children[childIndex]) {
selectedChildren.push(children[childIndex]);
}
availableIndices.splice(randomIndex, 1);
}
if (controls.selectionTiming === SelectionTiming.ONCE) {
controls.selectionCountStatus = true;
}
for (const child of children) {
if (!selectedChildren.includes(child)) {
child.isHiddenFromChoice = true;
child.isAvailable = false;
}
}
return selectedChildren;
}
/**
* Randomize Children Process (SR.2)
* Randomizes the order of child activities based on randomization controls
* @param {Activity} activity - The parent activity whose children need to be randomized
*