scorm-again 3.0.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +96 -5
  2. package/dist/cross-frame-api.js.map +1 -1
  3. package/dist/esm/cross-frame-api.js.map +1 -1
  4. package/dist/esm/scorm-again.js +503 -138
  5. package/dist/esm/scorm-again.js.map +1 -1
  6. package/dist/esm/scorm-again.min.js +1 -1
  7. package/dist/esm/scorm-again.min.js.map +1 -1
  8. package/dist/esm/scorm12.js +339 -73
  9. package/dist/esm/scorm12.js.map +1 -1
  10. package/dist/esm/scorm12.min.js +1 -1
  11. package/dist/esm/scorm12.min.js.map +1 -1
  12. package/dist/esm/scorm2004.js +486 -131
  13. package/dist/esm/scorm2004.js.map +1 -1
  14. package/dist/esm/scorm2004.min.js +1 -1
  15. package/dist/esm/scorm2004.min.js.map +1 -1
  16. package/dist/scorm-again.js +443 -119
  17. package/dist/scorm-again.js.map +1 -1
  18. package/dist/scorm-again.min.js +1 -1
  19. package/dist/scorm-again.min.js.map +1 -1
  20. package/dist/scorm12.js +316 -57
  21. package/dist/scorm12.js.map +1 -1
  22. package/dist/scorm12.min.js +1 -1
  23. package/dist/scorm12.min.js.map +1 -1
  24. package/dist/scorm2004.js +431 -112
  25. package/dist/scorm2004.js.map +1 -1
  26. package/dist/scorm2004.min.js +1 -1
  27. package/dist/scorm2004.min.js.map +1 -1
  28. package/dist/types/BaseAPI.d.ts +15 -5
  29. package/dist/types/Scorm12API.d.ts +2 -2
  30. package/dist/types/Scorm2004API.d.ts +3 -2
  31. package/dist/types/cmi/common/score.d.ts +2 -0
  32. package/dist/types/cmi/scorm2004/interaction_delimiters.d.ts +4 -0
  33. package/dist/types/interfaces/services.d.ts +8 -4
  34. package/dist/types/services/AsynchronousHttpService.d.ts +3 -2
  35. package/dist/types/services/EventService.d.ts +2 -2
  36. package/dist/types/services/OfflineStorageService.d.ts +6 -1
  37. package/dist/types/services/SynchronousHttpService.d.ts +2 -2
  38. package/dist/types/services/ValidationService.d.ts +1 -1
  39. package/dist/types/types/api_types.d.ts +22 -2
  40. package/dist/types/utilities/index.d.ts +1 -0
  41. package/dist/types/utilities/url.d.ts +2 -0
  42. package/package.json +46 -25
@@ -268,6 +268,15 @@ function parseNavigationRequest(navRequest) {
268
268
  };
269
269
  }
270
270
 
271
+ const appendQueryParam = (url, name, value) => {
272
+ const fragmentIndex = url.indexOf("#");
273
+ const baseUrl = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
274
+ const fragment = fragmentIndex === -1 ? "" : url.slice(fragmentIndex);
275
+ const separator = baseUrl.includes("?") ? baseUrl.endsWith("?") || baseUrl.endsWith("&") ? "" : "&" : "?";
276
+ const queryParam = `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`;
277
+ return `${baseUrl}${separator}${queryParam}${fragment}`;
278
+ };
279
+
271
280
  class BaseCMI {
272
281
  /**
273
282
  * Flag used during JSON serialization to allow getter access without initialization checks.
@@ -659,7 +668,10 @@ const scorm12_errors = {
659
668
  READ_ONLY_ELEMENT: 403,
660
669
  WRITE_ONLY_ELEMENT: 404,
661
670
  TYPE_MISMATCH: 405,
662
- VALUE_OUT_OF_RANGE: 407,
671
+ // SCORM 1.2 has no out-of-range error code; an out-of-range value is reported
672
+ // as 405 (incorrect data type), per the SCORM 1.2 RTE error table and the ADL
673
+ // 1.2 CTS (DataModelValidator.checkScoreDecimal failure -> CMICategory 405).
674
+ VALUE_OUT_OF_RANGE: 405,
663
675
  DEPENDENCY_NOT_ESTABLISHED: 408
664
676
  };
665
677
  const scorm2004_errors = {
@@ -807,6 +819,7 @@ const DefaultSettings = {
807
819
  xhrWithCredentials: false,
808
820
  fetchMode: "cors",
809
821
  asyncModeBeaconBehavior: "never",
822
+ includeCommitSequence: false,
810
823
  responseHandler: async function(response) {
811
824
  if (typeof response !== "undefined") {
812
825
  let httpResult = null;
@@ -1160,6 +1173,11 @@ const scorm2004_regex = {
1160
1173
  progress_range: "0#1"
1161
1174
  };
1162
1175
 
1176
+ const PERFORMANCE_STEP_NAME = "^$|" + scorm2004_regex.CMIShortIdentifier;
1177
+ const PERFORMANCE_CHARACTERSTRING = "(?![\\s\\S]*(?:\\[,\\]|\\[\\.\\]|\\[:\\]))[\\s\\S]{1,250}";
1178
+ const PERFORMANCE_NUMERIC_RANGE = "(?:-?\\d+(?:\\.\\d+)?)?\\[:\\](?:-?\\d+(?:\\.\\d+)?)?";
1179
+ const CR_PERFORMANCE_STEP_ANSWER = "^(?:|" + PERFORMANCE_NUMERIC_RANGE + "|" + PERFORMANCE_CHARACTERSTRING + ")$";
1180
+ const LR_PERFORMANCE_STEP_ANSWER = "^(?:|" + PERFORMANCE_CHARACTERSTRING + ")$";
1163
1181
  const LearnerResponses = {
1164
1182
  "true-false": {
1165
1183
  format: "^true$|^false$",
@@ -1194,8 +1212,8 @@ const LearnerResponses = {
1194
1212
  unique: false
1195
1213
  },
1196
1214
  performance: {
1197
- format: "^$|" + scorm2004_regex.CMIShortIdentifier,
1198
- format2: scorm2004_regex.CMIDecimal + "|^$|" + scorm2004_regex.CMIShortIdentifier,
1215
+ format: PERFORMANCE_STEP_NAME,
1216
+ format2: LR_PERFORMANCE_STEP_ANSWER,
1199
1217
  max: 250,
1200
1218
  delimiter: "[,]",
1201
1219
  delimiter2: "[.]",
@@ -1271,10 +1289,10 @@ const CorrectResponses = {
1271
1289
  delimiter2: "[.]",
1272
1290
  unique: false,
1273
1291
  duplicate: false,
1274
- // step_name must be a non-empty short identifier
1275
- format: scorm2004_regex.CMIShortIdentifier,
1276
- // step_answer may be short identifier or numeric range (<decimal>[:<decimal>])
1277
- format2: `^(${scorm2004_regex.CMIShortIdentifier})$|^(?:\\d+(?:\\.\\d+)?(?::\\d+(?:\\.\\d+)?)?)$`
1292
+ // step_name: optional short_identifier_type
1293
+ format: PERFORMANCE_STEP_NAME,
1294
+ // step_answer: optional characterstring (spaces allowed) or numeric range
1295
+ format2: CR_PERFORMANCE_STEP_ANSWER
1278
1296
  },
1279
1297
  sequencing: {
1280
1298
  max: 36,
@@ -1734,7 +1752,7 @@ class ScheduledCommit {
1734
1752
  wrapper() {
1735
1753
  if (!this._cancelled) {
1736
1754
  if (this._API.isInitialized()) {
1737
- (async () => await this._API.commit(this._callback))();
1755
+ (async () => await this._API.commit(this._callback, false, "autocommit"))();
1738
1756
  }
1739
1757
  }
1740
1758
  }
@@ -5079,6 +5097,7 @@ class ActivityDeliveryService {
5079
5097
  }
5080
5098
 
5081
5099
  class AsynchronousHttpService {
5100
+ reportsRequestCompletion = true;
5082
5101
  settings;
5083
5102
  error_codes;
5084
5103
  /**
@@ -5103,10 +5122,20 @@ class AsynchronousHttpService {
5103
5122
  * @param {boolean} immediate - Whether to send the request immediately without waiting
5104
5123
  * @param {Function} apiLog - Function to log API messages with appropriate levels
5105
5124
  * @param {Function} processListeners - Function to trigger event listeners for commit events
5125
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
5126
+ * @param {Function} onRequestComplete - Callback invoked after the background request settles
5106
5127
  * @return {ResultObject} - Immediate optimistic success result
5107
5128
  */
5108
- processHttpRequest(url, params, immediate = false, apiLog, processListeners) {
5109
- this._performAsyncRequest(url, params, immediate, apiLog, processListeners);
5129
+ processHttpRequest(url, params, immediate = false, apiLog, processListeners, metadata, onRequestComplete) {
5130
+ this._performAsyncRequest(
5131
+ url,
5132
+ params,
5133
+ immediate,
5134
+ apiLog,
5135
+ processListeners,
5136
+ metadata,
5137
+ onRequestComplete
5138
+ );
5110
5139
  return {
5111
5140
  result: global_constants.SCORM_TRUE,
5112
5141
  errorCode: 0
@@ -5119,11 +5148,14 @@ class AsynchronousHttpService {
5119
5148
  * @param {boolean} immediate - Whether this is an immediate request
5120
5149
  * @param apiLog - Function to log API messages
5121
5150
  * @param {Function} processListeners - Function to process event listeners
5151
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
5152
+ * @param {Function} onRequestComplete - Callback invoked after the request settles
5122
5153
  * @private
5123
5154
  */
5124
- async _performAsyncRequest(url, params, immediate, apiLog, processListeners) {
5155
+ async _performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete) {
5125
5156
  try {
5126
- const processedParams = this.settings.requestHandler(params);
5157
+ const handledParams = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
5158
+ const processedParams = handledParams;
5127
5159
  let response;
5128
5160
  if (immediate && this.settings.asyncModeBeaconBehavior !== "never") {
5129
5161
  response = await this.performBeacon(url, processedParams);
@@ -5139,7 +5171,9 @@ class AsynchronousHttpService {
5139
5171
  } catch (e) {
5140
5172
  const message = e instanceof Error ? e.message : String(e);
5141
5173
  apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR);
5142
- processListeners("CommitError");
5174
+ processListeners("CommitError", void 0, this.error_codes.GENERAL_COMMIT_FAILURE || 391);
5175
+ } finally {
5176
+ onRequestComplete?.();
5143
5177
  }
5144
5178
  }
5145
5179
  /**
@@ -5282,10 +5316,13 @@ class CMIValueAccessService {
5282
5316
  }
5283
5317
  /**
5284
5318
  * Gets the appropriate error code for undefined data model elements.
5285
- * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL.
5319
+ * Both SCORM 2004 and SCORM 1.2 use UNDEFINED_DATA_MODEL (401): an
5320
+ * unrecognized element is "Not implemented", not a general exception. SCORM
5321
+ * 1.2 previously returned GENERAL (101) here, which is non-conformant — the
5322
+ * ADL 1.2 CTS and the SCORM 1.2 RTE spec expect 401 for an unknown element.
5286
5323
  */
5287
- getUndefinedDataModelErrorCode(scorm2004) {
5288
- return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL");
5324
+ getUndefinedDataModelErrorCode() {
5325
+ return getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL");
5289
5326
  }
5290
5327
  /**
5291
5328
  * Sets a value on a CMI element path
@@ -5313,7 +5350,7 @@ class CMIValueAccessService {
5313
5350
  let returnValue = global_constants.SCORM_FALSE;
5314
5351
  let foundFirstIndex = false;
5315
5352
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5316
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5353
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5317
5354
  for (let idx = 0; idx < structure.length; idx++) {
5318
5355
  const attribute = structure[idx];
5319
5356
  if (idx === structure.length - 1) {
@@ -5383,12 +5420,13 @@ class CMIValueAccessService {
5383
5420
  );
5384
5421
  return "";
5385
5422
  }
5423
+ this.context.setLastErrorCode("0");
5386
5424
  const structure = CMIElement.split(".");
5387
5425
  let refObject = this.context.getDataModel();
5388
5426
  let attribute = null;
5389
5427
  const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
5390
5428
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5391
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5429
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5392
5430
  for (let idx = 0; idx < structure.length; idx++) {
5393
5431
  attribute = structure[idx];
5394
5432
  const validationResult = this.validateGetAttribute(
@@ -5583,7 +5621,19 @@ class CMIValueAccessService {
5583
5621
  if (!scorm2004) {
5584
5622
  if (isFinalAttribute) {
5585
5623
  if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) {
5586
- this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
5624
+ if (attribute === "_children") {
5625
+ this.context.throwSCORMError(
5626
+ CMIElement,
5627
+ getErrorCode(this.context.errorCodes, "CHILDREN_ERROR")
5628
+ );
5629
+ } else if (attribute === "_count") {
5630
+ this.context.throwSCORMError(
5631
+ CMIElement,
5632
+ getErrorCode(this.context.errorCodes, "COUNT_ERROR")
5633
+ );
5634
+ } else {
5635
+ this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
5636
+ }
5587
5637
  return { error: true };
5588
5638
  }
5589
5639
  }
@@ -6088,8 +6138,9 @@ class EventService {
6088
6138
  * @param {string} functionName - The name of the function that triggered the event
6089
6139
  * @param {string} CMIElement - The CMI element that was affected
6090
6140
  * @param {any} value - The value that was set
6141
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
6091
6142
  */
6092
- processListeners(functionName, CMIElement, value) {
6143
+ processListeners(functionName, CMIElement, value, context) {
6093
6144
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
6094
6145
  const listeners = this.listenerMap.get(functionName);
6095
6146
  if (!listeners) return;
@@ -6114,9 +6165,17 @@ class EventService {
6114
6165
  if (functionName.startsWith("Sequence")) {
6115
6166
  listener.callback(value);
6116
6167
  } else if (functionName === "CommitError") {
6117
- listener.callback(value);
6168
+ if (context !== void 0) {
6169
+ listener.callback(value, context);
6170
+ } else {
6171
+ listener.callback(value);
6172
+ }
6118
6173
  } else if (functionName === "CommitSuccess") {
6119
- listener.callback();
6174
+ if (context !== void 0) {
6175
+ listener.callback(context);
6176
+ } else {
6177
+ listener.callback();
6178
+ }
6120
6179
  } else {
6121
6180
  listener.callback(CMIElement, value);
6122
6181
  }
@@ -6250,16 +6309,19 @@ class OfflineStorageService {
6250
6309
  * Store commit data offline
6251
6310
  * @param {string} courseId - Identifier for the course
6252
6311
  * @param {CommitObject} commitData - The data to store offline
6312
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6253
6313
  * @returns {ResultObject} - Result of the storage operation
6254
6314
  */
6255
- storeOffline(courseId, commitData) {
6315
+ storeOffline(courseId, commitData, metadata) {
6256
6316
  try {
6257
6317
  const queueItem = {
6258
6318
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
6259
6319
  courseId,
6260
6320
  timestamp: Date.now(),
6261
6321
  data: commitData,
6262
- syncAttempts: 0
6322
+ syncAttempts: 0,
6323
+ ...metadata?.isTerminateCommit !== void 0 ? { isTerminateCommit: metadata.isTerminateCommit } : {},
6324
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6263
6325
  };
6264
6326
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
6265
6327
  currentQueue.push(queueItem);
@@ -6338,7 +6400,10 @@ class OfflineStorageService {
6338
6400
  continue;
6339
6401
  }
6340
6402
  try {
6341
- const syncResult = await this.sendDataToLMS(item.data);
6403
+ const syncResult = await this.sendDataToLMS(item.data, {
6404
+ ...item.isTerminateCommit !== void 0 ? { isTerminateCommit: item.isTerminateCommit } : {},
6405
+ ...item.sequence !== void 0 ? { sequence: item.sequence } : {}
6406
+ });
6342
6407
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
6343
6408
  this.apiLog(
6344
6409
  "OfflineStorageService",
@@ -6385,17 +6450,25 @@ class OfflineStorageService {
6385
6450
  /**
6386
6451
  * Send data to the LMS when online
6387
6452
  * @param {CommitObject} data - The data to send to the LMS
6453
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6388
6454
  * @returns {Promise<ResultObject>} - Result of the sync operation
6389
6455
  */
6390
- async sendDataToLMS(data) {
6391
- if (!this.settings.lmsCommitUrl) {
6456
+ async sendDataToLMS(data, metadata) {
6457
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
6458
+ if (!configuredCommitUrl) {
6392
6459
  return {
6393
6460
  result: global_constants.SCORM_FALSE,
6394
6461
  errorCode: this.error_codes.GENERAL || 101
6395
6462
  };
6396
6463
  }
6397
6464
  try {
6398
- const processedData = this.settings.requestHandler(data);
6465
+ const lmsCommitUrl = String(configuredCommitUrl);
6466
+ const processedData = this.settings.requestHandler(data, {
6467
+ isTerminateCommit: metadata?.isTerminateCommit ?? false,
6468
+ trigger: "offline-replay",
6469
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6470
+ });
6471
+ const requestUrl = metadata?.isTerminateCommit && this.settings.terminateCommitParam ? appendQueryParam(lmsCommitUrl, this.settings.terminateCommitParam, "true") : lmsCommitUrl;
6399
6472
  const init = {
6400
6473
  method: "POST",
6401
6474
  mode: this.settings.fetchMode,
@@ -6408,7 +6481,7 @@ class OfflineStorageService {
6408
6481
  if (this.settings.xhrWithCredentials) {
6409
6482
  init.credentials = "include";
6410
6483
  }
6411
- const response = await fetch(this.settings.lmsCommitUrl, init);
6484
+ const response = await fetch(requestUrl, init);
6412
6485
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
6413
6486
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
6414
6487
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -15373,6 +15446,8 @@ class SynchronousHttpService {
15373
15446
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
15374
15447
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
15375
15448
  * @param {Function} _processListeners - Function to trigger event listeners (unused in synchronous mode - no async events)
15449
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15450
+ * @param {Function} _onRequestComplete - Completion callback (unused because requests settle before return)
15376
15451
  * @return {ResultObject} - The result of the request (synchronous)
15377
15452
  *
15378
15453
  * @remarks
@@ -15382,21 +15457,23 @@ class SynchronousHttpService {
15382
15457
  * - No async events need to be triggered (CommitSuccess/CommitError) since results are synchronous
15383
15458
  * - AsynchronousHttpService uses these parameters to handle background request results
15384
15459
  */
15385
- processHttpRequest(url, params, immediate = false, _apiLog, _processListeners) {
15460
+ processHttpRequest(url, params, immediate = false, _apiLog, _processListeners, metadata, _onRequestComplete) {
15386
15461
  if (immediate) {
15387
- return this._handleImmediateRequest(url, params);
15462
+ return this._handleImmediateRequest(url, params, metadata);
15388
15463
  }
15389
- return this._performSyncXHR(url, params);
15464
+ return this._performSyncXHR(url, params, metadata);
15390
15465
  }
15391
15466
  /**
15392
15467
  * Handles an immediate request using sendBeacon
15393
15468
  * @param {string} url - The URL to send the request to
15394
15469
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15470
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15395
15471
  * @return {ResultObject} - The result based on beacon success
15396
15472
  * @private
15397
15473
  */
15398
- _handleImmediateRequest(url, params) {
15399
- const requestPayload = this.settings.requestHandler(params) ?? params;
15474
+ _handleImmediateRequest(url, params, metadata) {
15475
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15476
+ const requestPayload = handledPayload ?? params;
15400
15477
  const { body } = this._prepareRequestBody(requestPayload);
15401
15478
  const beaconSuccess = navigator.sendBeacon(
15402
15479
  url,
@@ -15411,11 +15488,13 @@ class SynchronousHttpService {
15411
15488
  * Performs a synchronous XMLHttpRequest
15412
15489
  * @param {string} url - The URL to send the request to
15413
15490
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15491
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15414
15492
  * @return {ResultObject} - The result of the request
15415
15493
  * @private
15416
15494
  */
15417
- _performSyncXHR(url, params) {
15418
- const requestPayload = this.settings.requestHandler(params) ?? params;
15495
+ _performSyncXHR(url, params, metadata) {
15496
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15497
+ const requestPayload = handledPayload ?? params;
15419
15498
  const { body, contentType } = this._prepareRequestBody(requestPayload);
15420
15499
  const xhr = new XMLHttpRequest();
15421
15500
  xhr.open("POST", url, false);
@@ -15494,9 +15573,15 @@ class ValidationService {
15494
15573
  * @param {number} invalidTypeCode - The error code for invalid type
15495
15574
  * @param {number} invalidRangeCode - The error code for invalid range
15496
15575
  * @param {typeof BaseScormValidationError} errorClass - The error class to use for validation errors
15576
+ * @param {boolean} allowEmptyString - When true, an empty string is accepted (clears the value).
15577
+ * SCORM 1.2 score elements may be blank per the ADL 1.2 CTS
15578
+ * (DataModelValidator.checkScoreDecimal treats blank as valid).
15497
15579
  * @return {boolean} - True if validation passes, throws an error otherwise
15498
15580
  */
15499
- validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass) {
15581
+ validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass, allowEmptyString = false) {
15582
+ if (allowEmptyString && value === "") {
15583
+ return true;
15584
+ }
15500
15585
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
15501
15586
  }
15502
15587
  /**
@@ -15570,6 +15655,21 @@ class BaseAPI {
15570
15655
  _offlineStorageService;
15571
15656
  _cmiValueAccessService;
15572
15657
  _courseId = "";
15658
+ _pendingCommitCount = 0;
15659
+ /**
15660
+ * Monotonic sequence for commits captured by this API instance. It is
15661
+ * intentionally not reset by reset().
15662
+ */
15663
+ _commitSequence = 0;
15664
+ _commitSettleWaiters = [];
15665
+ /**
15666
+ * Canonical paths of every CMI element that has been explicitly assigned a
15667
+ * value via SetValue / loadFromJSON (both funnel through _commonSetCMIValue).
15668
+ * Used by standards that must tell "implemented but never set" apart from a
15669
+ * legitimately empty value when answering GetValue (SCORM 2004 error 403).
15670
+ * Cleared on reset so a fresh SCO attempt starts with nothing "set".
15671
+ */
15672
+ _setCMIElements = /* @__PURE__ */ new Set();
15573
15673
  /**
15574
15674
  * Constructor for Base API class. Sets some shared API fields, as well as
15575
15675
  * sets up options for the API.
@@ -15755,6 +15855,7 @@ class BaseAPI {
15755
15855
  this.lastErrorCode = "0";
15756
15856
  this._eventService.reset();
15757
15857
  this.startingData = {};
15858
+ this._setCMIElements.clear();
15758
15859
  if (this._offlineStorageService) {
15759
15860
  this._offlineStorageService.updateSettings(this.settings);
15760
15861
  if (settings?.courseId) {
@@ -15839,6 +15940,52 @@ class BaseAPI {
15839
15940
  this._loggingService?.setLogHandler(settings.onLogMessage);
15840
15941
  }
15841
15942
  }
15943
+ /**
15944
+ * Gets the number of captured commit requests that have not yet settled.
15945
+ *
15946
+ * @return {number} The number of in-flight commits
15947
+ */
15948
+ get pendingCommitCount() {
15949
+ return this._pendingCommitCount;
15950
+ }
15951
+ /**
15952
+ * Resolves when all currently in-flight commits have settled. A timeout is
15953
+ * best-effort: the promise resolves when it elapses even if commits remain,
15954
+ * and callers can inspect pendingCommitCount afterward to detect that case.
15955
+ *
15956
+ * @param {Object} [options] - Settle options
15957
+ * @param {number} [options.timeoutMs] - Maximum time to wait in milliseconds
15958
+ * @return {Promise<void>} A promise that resolves after the drain or timeout
15959
+ */
15960
+ whenCommitsSettled(options) {
15961
+ if (this._pendingCommitCount === 0) {
15962
+ return Promise.resolve();
15963
+ }
15964
+ return new Promise((resolve) => {
15965
+ const waiter = { resolve };
15966
+ if (options?.timeoutMs !== void 0) {
15967
+ waiter.timeoutId = setTimeout(() => {
15968
+ const waiterIndex = this._commitSettleWaiters.indexOf(waiter);
15969
+ if (waiterIndex === -1) {
15970
+ return;
15971
+ }
15972
+ this._commitSettleWaiters.splice(waiterIndex, 1);
15973
+ resolve();
15974
+ }, options.timeoutMs);
15975
+ }
15976
+ this._commitSettleWaiters.push(waiter);
15977
+ });
15978
+ }
15979
+ /** Resolve and clear every waiter after the pending count reaches zero. */
15980
+ _flushCommitSettleWaiters() {
15981
+ const waiters = this._commitSettleWaiters.splice(0);
15982
+ for (const waiter of waiters) {
15983
+ if (waiter.timeoutId !== void 0) {
15984
+ clearTimeout(waiter.timeoutId);
15985
+ }
15986
+ waiter.resolve();
15987
+ }
15988
+ }
15842
15989
  /**
15843
15990
  * Terminates the current run of the API
15844
15991
  * @param {string} callbackName
@@ -15859,7 +16006,7 @@ class BaseAPI {
15859
16006
  } else {
15860
16007
  stateCheckPassed = true;
15861
16008
  this.processListeners("BeforeTerminate");
15862
- const result = this.storeData(true);
16009
+ const result = this.storeData(true, "terminate");
15863
16010
  if ((result.errorCode ?? 0) > 0) {
15864
16011
  if (result.errorMessage) {
15865
16012
  this.apiLog(
@@ -15911,6 +16058,9 @@ class BaseAPI {
15911
16058
  } catch (e) {
15912
16059
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
15913
16060
  }
16061
+ if (this.lastErrorCode === "0") {
16062
+ this.checkUninitializedGet(CMIElement, returnValue);
16063
+ }
15914
16064
  this.processListeners(callbackName, CMIElement);
15915
16065
  }
15916
16066
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -15920,6 +16070,10 @@ class BaseAPI {
15920
16070
  if (this.lastErrorCode === "0") {
15921
16071
  this.clearSCORMError(returnValue);
15922
16072
  }
16073
+ const rawReturn = returnValue;
16074
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
16075
+ return String(rawReturn);
16076
+ }
15923
16077
  return returnValue;
15924
16078
  }
15925
16079
  /**
@@ -15972,9 +16126,10 @@ class BaseAPI {
15972
16126
  * Orders LMS to store all content parameters
15973
16127
  * @param {string} callbackName
15974
16128
  * @param {boolean} checkTerminated
16129
+ * @param {CommitTrigger} trigger - What initiated the commit
15975
16130
  * @return {string}
15976
16131
  */
15977
- commit(callbackName, checkTerminated = false) {
16132
+ commit(callbackName, checkTerminated = false, trigger = "manual") {
15978
16133
  this.clearScheduledCommit();
15979
16134
  let returnValue = global_constants.SCORM_TRUE;
15980
16135
  if (this.isNotInitialized()) {
@@ -15986,8 +16141,9 @@ class BaseAPI {
15986
16141
  this.throwSCORMError("api", errorCode);
15987
16142
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
15988
16143
  } else {
15989
- const result = this.storeData(false);
15990
- if ((result.errorCode ?? 0) > 0) {
16144
+ const result = this.storeData(false, trigger);
16145
+ const errorCode = result.errorCode ?? 0;
16146
+ if (errorCode > 0) {
15991
16147
  if (result.errorMessage) {
15992
16148
  this.apiLog(
15993
16149
  "commit",
@@ -16002,12 +16158,12 @@ class BaseAPI {
16002
16158
  LogLevelEnum.DEBUG
16003
16159
  );
16004
16160
  }
16005
- this.throwSCORMError("api", result.errorCode);
16161
+ this.throwSCORMError("api", errorCode);
16006
16162
  }
16007
16163
  const resultValue = result?.result ?? global_constants.SCORM_FALSE;
16008
16164
  returnValue = typeof resultValue === "boolean" ? String(resultValue) : resultValue;
16009
16165
  this.apiLog(callbackName, " Result: " + returnValue, LogLevelEnum.DEBUG, "HttpRequest");
16010
- if (checkTerminated) this.lastErrorCode = "0";
16166
+ if (checkTerminated && errorCode === 0) this.lastErrorCode = "0";
16011
16167
  this.processListeners(callbackName);
16012
16168
  if (this.settings.enableOfflineSupport && this._offlineStorageService && this._offlineStorageService.isDeviceOnline() && this._courseId) {
16013
16169
  this._offlineStorageService.hasPendingOfflineData(this._courseId).then((hasPendingData) => {
@@ -16216,7 +16372,16 @@ class BaseAPI {
16216
16372
  * @return {string}
16217
16373
  */
16218
16374
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
16219
- return this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
16375
+ const result = this._cmiValueAccessService.setCMIValue(
16376
+ methodName,
16377
+ scorm2004,
16378
+ CMIElement,
16379
+ value
16380
+ );
16381
+ if (result === global_constants.SCORM_TRUE) {
16382
+ this._setCMIElements.add(CMIElement);
16383
+ }
16384
+ return result;
16220
16385
  }
16221
16386
  /**
16222
16387
  * Gets a value from the CMI Object.
@@ -16230,6 +16395,18 @@ class BaseAPI {
16230
16395
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
16231
16396
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
16232
16397
  }
16398
+ /**
16399
+ * Hook invoked by getValue after a successful resolution. Standards that must
16400
+ * distinguish "implemented but never set, no default value" from a
16401
+ * legitimately empty value override this to raise VALUE_NOT_INITIALIZED.
16402
+ * Default is a no-op, so SCORM 1.2 / AICC keep returning "" with code 0.
16403
+ *
16404
+ * @param {string} _CMIElement - the element that was read
16405
+ * @param {any} _returnValue - the value getCMIValue resolved
16406
+ * @protected
16407
+ */
16408
+ checkUninitializedGet(_CMIElement, _returnValue) {
16409
+ }
16233
16410
  /**
16234
16411
  * Returns true if the API's current state is STATE_INITIALIZED
16235
16412
  *
@@ -16312,9 +16489,14 @@ class BaseAPI {
16312
16489
  * @param {string} functionName - The name of the function/event that occurred
16313
16490
  * @param {string} CMIElement - Optional CMI element involved in the event
16314
16491
  * @param {any} value - Optional value associated with the event
16492
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
16315
16493
  */
16316
- processListeners(functionName, CMIElement, value) {
16317
- this._eventService.processListeners(functionName, CMIElement, value);
16494
+ processListeners(functionName, CMIElement, value, context) {
16495
+ if (context !== void 0) {
16496
+ this._eventService.processListeners(functionName, CMIElement, value, context);
16497
+ } else {
16498
+ this._eventService.processListeners(functionName, CMIElement, value);
16499
+ }
16318
16500
  }
16319
16501
  /**
16320
16502
  * Throws a SCORM error with the specified error number and optional message.
@@ -16447,36 +16629,106 @@ class BaseAPI {
16447
16629
  * @param {string} url - The URL to send the request to
16448
16630
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
16449
16631
  * @param {boolean} immediate - Whether to send the request immediately without waiting
16632
+ * @param {CommitTrigger} [trigger] - What initiated the commit
16450
16633
  * @returns {ResultObject} - The result of the request
16451
16634
  */
16452
- processHttpRequest(url, params, immediate = false) {
16453
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16454
- this.apiLog(
16455
- "processHttpRequest",
16456
- "Device is offline, storing data locally",
16457
- LogLevelEnum.INFO
16458
- );
16459
- if (params && typeof params === "object" && "cmi" in params) {
16460
- return this._offlineStorageService.storeOffline(this._courseId, params);
16461
- } else {
16635
+ processHttpRequest(url, params, immediate = false, trigger) {
16636
+ const sequence = ++this._commitSequence;
16637
+ this._pendingCommitCount += 1;
16638
+ let settled = false;
16639
+ let completionDeferred = false;
16640
+ const settle = () => {
16641
+ if (settled) {
16642
+ return;
16643
+ }
16644
+ settled = true;
16645
+ this._pendingCommitCount -= 1;
16646
+ if (this._pendingCommitCount === 0) {
16647
+ this._flushCommitSettleWaiters();
16648
+ }
16649
+ };
16650
+ try {
16651
+ const resolvedTrigger = trigger ?? (immediate ? "terminate" : "manual");
16652
+ let finalParams = params;
16653
+ if (immediate && this.settings.terminateCommitPayloadField) {
16654
+ const field = this.settings.terminateCommitPayloadField;
16655
+ if (Array.isArray(finalParams)) {
16656
+ finalParams = [...finalParams, `${encodeURIComponent(field)}=true`];
16657
+ } else if (finalParams && typeof finalParams === "object") {
16658
+ finalParams = { ...finalParams, [field]: true };
16659
+ }
16660
+ }
16661
+ if (this.settings.includeCommitSequence === true) {
16662
+ if (Array.isArray(finalParams)) {
16663
+ finalParams = [...finalParams, `commitSequence=${sequence}`];
16664
+ } else if (finalParams && typeof finalParams === "object") {
16665
+ finalParams = { ...finalParams, commitSequence: sequence };
16666
+ }
16667
+ }
16668
+ const finalUrl = immediate && this.settings.terminateCommitParam ? appendQueryParam(url, this.settings.terminateCommitParam, "true") : url;
16669
+ const metadata = {
16670
+ isTerminateCommit: immediate,
16671
+ trigger: resolvedTrigger,
16672
+ sequence
16673
+ };
16674
+ const context = {
16675
+ url: finalUrl,
16676
+ trigger: resolvedTrigger,
16677
+ isTerminateCommit: immediate,
16678
+ sequence
16679
+ };
16680
+ if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16462
16681
  this.apiLog(
16463
16682
  "processHttpRequest",
16464
- "Invalid commit data format for offline storage",
16465
- LogLevelEnum.ERROR
16683
+ "Device is offline, storing data locally",
16684
+ LogLevelEnum.INFO
16466
16685
  );
16467
- return {
16468
- result: global_constants.SCORM_FALSE,
16469
- errorCode: this._error_codes.GENERAL ?? 101
16470
- };
16686
+ if (finalParams && typeof finalParams === "object" && "cmi" in finalParams) {
16687
+ return this._offlineStorageService.storeOffline(
16688
+ this._courseId,
16689
+ finalParams,
16690
+ { isTerminateCommit: immediate, sequence }
16691
+ );
16692
+ } else {
16693
+ this.apiLog(
16694
+ "processHttpRequest",
16695
+ "Invalid commit data format for offline storage",
16696
+ LogLevelEnum.ERROR
16697
+ );
16698
+ return {
16699
+ result: global_constants.SCORM_FALSE,
16700
+ errorCode: this._error_codes.GENERAL ?? 101
16701
+ };
16702
+ }
16703
+ }
16704
+ const apiLog = (functionName, message, level, element) => this.apiLog(functionName, message, level, element);
16705
+ const processListeners = (functionName, CMIElement, value) => {
16706
+ if (functionName === "CommitSuccess" || functionName === "CommitError") {
16707
+ if (functionName === "CommitError" && typeof value === "number") {
16708
+ context.errorCode = value;
16709
+ }
16710
+ settle();
16711
+ this.processListeners(functionName, CMIElement, value, context);
16712
+ } else {
16713
+ this.processListeners(functionName, CMIElement, value);
16714
+ }
16715
+ };
16716
+ const result = this._httpService.processHttpRequest(
16717
+ finalUrl,
16718
+ finalParams,
16719
+ immediate,
16720
+ apiLog,
16721
+ processListeners,
16722
+ metadata,
16723
+ settle
16724
+ );
16725
+ completionDeferred = this._httpService.reportsRequestCompletion === true;
16726
+ return result;
16727
+ } finally {
16728
+ if (!completionDeferred) {
16729
+ settle();
16471
16730
  }
16472
16731
  }
16473
- return this._httpService.processHttpRequest(
16474
- url,
16475
- params,
16476
- immediate,
16477
- (functionName, message, level, element) => this.apiLog(functionName, message, level, element),
16478
- (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value)
16479
- );
16480
16732
  }
16481
16733
  /**
16482
16734
  * Schedules a commit operation to occur after a specified delay.
@@ -16636,6 +16888,11 @@ class CMIScore extends BaseCMI {
16636
16888
  __invalid_range_code;
16637
16889
  __decimal_regex;
16638
16890
  __error_class;
16891
+ /**
16892
+ * When true, an empty string is a valid value (clears the element). SCORM 1.2
16893
+ * score elements may be blank per the ADL 1.2 CTS; SCORM 2004 leaves this off.
16894
+ */
16895
+ __allow_empty_string;
16639
16896
  _raw = "";
16640
16897
  _min = "";
16641
16898
  _max;
@@ -16670,6 +16927,7 @@ class CMIScore extends BaseCMI {
16670
16927
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
16671
16928
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
16672
16929
  this.__error_class = params.errorClass;
16930
+ this.__allow_empty_string = params.allowEmptyString ?? false;
16673
16931
  }
16674
16932
  /**
16675
16933
  * Called when the API has been reset
@@ -16716,7 +16974,8 @@ class CMIScore extends BaseCMI {
16716
16974
  this.__score_range,
16717
16975
  this.__invalid_type_code,
16718
16976
  this.__invalid_range_code,
16719
- this.__error_class
16977
+ this.__error_class,
16978
+ this.__allow_empty_string
16720
16979
  )) {
16721
16980
  this._raw = raw;
16722
16981
  }
@@ -16740,7 +16999,8 @@ class CMIScore extends BaseCMI {
16740
16999
  this.__score_range,
16741
17000
  this.__invalid_type_code,
16742
17001
  this.__invalid_range_code,
16743
- this.__error_class
17002
+ this.__error_class,
17003
+ this.__allow_empty_string
16744
17004
  )) {
16745
17005
  this._min = min;
16746
17006
  }
@@ -16764,7 +17024,8 @@ class CMIScore extends BaseCMI {
16764
17024
  this.__score_range,
16765
17025
  this.__invalid_type_code,
16766
17026
  this.__invalid_range_code,
16767
- this.__error_class
17027
+ this.__error_class,
17028
+ this.__allow_empty_string
16768
17029
  )) {
16769
17030
  this._max = max;
16770
17031
  }
@@ -16821,7 +17082,9 @@ class CMICore extends BaseCMI {
16821
17082
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
16822
17083
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
16823
17084
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
16824
- errorClass: Scorm12ValidationError
17085
+ errorClass: Scorm12ValidationError,
17086
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
17087
+ allowEmptyString: true
16825
17088
  });
16826
17089
  }
16827
17090
  score;
@@ -17251,7 +17514,9 @@ let CMIObjectivesObject$1 = class CMIObjectivesObject extends BaseCMI {
17251
17514
  invalidErrorCode: scorm12_errors.INVALID_SET_VALUE,
17252
17515
  invalidTypeCode: scorm12_errors.TYPE_MISMATCH,
17253
17516
  invalidRangeCode: scorm12_errors.VALUE_OUT_OF_RANGE,
17254
- errorClass: Scorm12ValidationError
17517
+ errorClass: Scorm12ValidationError,
17518
+ // SCORM 1.2 score elements may be set blank (clears the value), per ADL 1.2 CTS.
17519
+ allowEmptyString: true
17255
17520
  });
17256
17521
  }
17257
17522
  score;
@@ -18721,16 +18986,17 @@ class Scorm12API extends BaseAPI {
18721
18986
  * Attempts to store the data to the LMS
18722
18987
  *
18723
18988
  * @param {boolean} terminateCommit
18989
+ * @param {CommitTrigger} [trigger] - What initiated the commit
18724
18990
  * @return {ResultObject}
18725
18991
  */
18726
- storeData(terminateCommit) {
18992
+ storeData(terminateCommit, trigger) {
18727
18993
  if (terminateCommit) {
18728
18994
  const originalStatus = this.cmi.core.lesson_status;
18729
18995
  if (this.cmi.core.lesson_mode === "browse") {
18730
18996
  const startingStatus = this.startingData?.cmi?.core?.lesson_status || "";
18731
18997
  if (startingStatus === "" && originalStatus === "not attempted") {
18732
18998
  this.cmi.core.lesson_status = "browsed";
18733
- return this.processCommitData(terminateCommit);
18999
+ return this.processCommitData(terminateCommit, trigger);
18734
19000
  }
18735
19001
  }
18736
19002
  if (!this.cmi.core.lesson_status || !this.statusSetByModule && this.cmi.core.lesson_status === "not attempted") {
@@ -18759,12 +19025,17 @@ class Scorm12API extends BaseAPI {
18759
19025
  }
18760
19026
  }
18761
19027
  }
18762
- return this.processCommitData(terminateCommit);
19028
+ return this.processCommitData(terminateCommit, trigger);
18763
19029
  }
18764
- processCommitData(terminateCommit) {
19030
+ processCommitData(terminateCommit, trigger) {
18765
19031
  const commitObject = this.getCommitObject(terminateCommit);
18766
19032
  if (typeof this.settings.lmsCommitUrl === "string") {
18767
- return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject, terminateCommit);
19033
+ return this.processHttpRequest(
19034
+ this.settings.lmsCommitUrl,
19035
+ commitObject,
19036
+ terminateCommit,
19037
+ trigger
19038
+ );
18768
19039
  } else {
18769
19040
  return {
18770
19041
  result: global_constants.SCORM_TRUE,
@@ -18930,6 +19201,49 @@ class CMILearnerPreference extends BaseCMI {
18930
19201
  }
18931
19202
  }
18932
19203
 
19204
+ function stripBrackets(delim) {
19205
+ return delim.replace(/[[\]]/g, "");
19206
+ }
19207
+ function escapeRegex(s) {
19208
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19209
+ }
19210
+ function splitDelimited(value, bracketed) {
19211
+ if (!bracketed) {
19212
+ return [value];
19213
+ }
19214
+ if (value.includes(bracketed)) {
19215
+ return value.split(bracketed);
19216
+ }
19217
+ const bare = stripBrackets(bracketed);
19218
+ if (!bare) {
19219
+ return [value];
19220
+ }
19221
+ const splitRe = new RegExp(`(?<!\\\\)${escapeRegex(bare)}`, "g");
19222
+ const unescapeRe = new RegExp(`\\\\${escapeRegex(bare)}`, "g");
19223
+ return value.split(splitRe).map((part) => part.replace(unescapeRe, bare));
19224
+ }
19225
+ function splitFirstDelimited(value, bracketed) {
19226
+ if (!bracketed) {
19227
+ return [value];
19228
+ }
19229
+ if (value.includes(bracketed)) {
19230
+ const idx = value.indexOf(bracketed);
19231
+ return [value.slice(0, idx), value.slice(idx + bracketed.length)];
19232
+ }
19233
+ const bare = stripBrackets(bracketed);
19234
+ if (!bare) {
19235
+ return [value];
19236
+ }
19237
+ const splitRe = new RegExp(`(?<!\\\\)${escapeRegex(bare)}`);
19238
+ const unescapeRe = new RegExp(`\\\\${escapeRegex(bare)}`, "g");
19239
+ const parts = value.split(splitRe);
19240
+ const first = (parts[0] ?? "").replace(unescapeRe, bare);
19241
+ if (parts.length === 1) {
19242
+ return [first];
19243
+ }
19244
+ return [first, parts.slice(1).join(bare).replace(unescapeRe, bare)];
19245
+ }
19246
+
18933
19247
  class CMIInteractions extends CMIArray {
18934
19248
  /**
18935
19249
  * Constructor for `cmi.interactions` Array
@@ -19139,8 +19453,7 @@ class CMIInteractionsObject extends BaseCMI {
19139
19453
  const response_type = LearnerResponses[this.type];
19140
19454
  if (response_type) {
19141
19455
  if (response_type?.delimiter) {
19142
- const delimiter = response_type.delimiter === "[,]" ? "," : response_type.delimiter;
19143
- nodes = learner_response.split(delimiter);
19456
+ nodes = splitDelimited(learner_response, response_type.delimiter);
19144
19457
  } else {
19145
19458
  nodes[0] = learner_response;
19146
19459
  }
@@ -19148,10 +19461,10 @@ class CMIInteractionsObject extends BaseCMI {
19148
19461
  const formatRegex = new RegExp(response_type.format);
19149
19462
  for (let i = 0; i < nodes.length; i++) {
19150
19463
  if (response_type?.delimiter2) {
19151
- const delimiter2 = response_type.delimiter2 === "[.]" ? "." : response_type.delimiter2;
19152
- const values = nodes[i]?.split(delimiter2);
19464
+ const node = nodes[i] ?? "";
19465
+ const values = this.type === "performance" ? splitFirstDelimited(node, response_type.delimiter2) : splitDelimited(node, response_type.delimiter2);
19153
19466
  if (values?.length === 2) {
19154
- if (this.type === "performance" && (values[0] === "" || values[1] === "")) {
19467
+ if (this.type === "performance" && values[0] === "" && values[1] === "") {
19155
19468
  throw new Scorm2004ValidationError(
19156
19469
  this._cmi_element + ".learner_response",
19157
19470
  scorm2004_errors.TYPE_MISMATCH
@@ -19370,30 +19683,13 @@ class CMIInteractionsObjectivesObject extends BaseCMI {
19370
19683
  return result;
19371
19684
  }
19372
19685
  }
19373
- function stripBrackets(delim) {
19374
- return delim.replace(/[[\]]/g, "");
19375
- }
19376
- function escapeRegex(s) {
19377
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19378
- }
19379
- function splitUnescaped(text, delim) {
19380
- const reDelim = escapeRegex(delim);
19381
- const splitRe = new RegExp(`(?<!\\\\)${reDelim}`, "g");
19382
- const unescapeRe = new RegExp(`\\\\${reDelim}`, "g");
19383
- return text.split(splitRe).map((part) => part.replace(unescapeRe, delim));
19384
- }
19385
- function splitFirstUnescaped(text, delim) {
19386
- const reDelim = escapeRegex(delim);
19387
- const splitRe = new RegExp(`(?<!\\\\)${reDelim}`);
19388
- const unescapeRe = new RegExp(`\\\\${reDelim}`, "g");
19389
- const parts = text.split(splitRe);
19390
- const firstPart = parts[0] ?? "";
19391
- if (parts.length === 1) {
19392
- return [firstPart.replace(unescapeRe, delim)];
19686
+ const RESPONSE_PREFIX_RE = /^\{(?:lang|case_matters|order_matters)=[^}]+\}/;
19687
+ function stripResponsePrefixes(node) {
19688
+ let result = node;
19689
+ while (RESPONSE_PREFIX_RE.test(result)) {
19690
+ result = result.replace(RESPONSE_PREFIX_RE, "");
19393
19691
  }
19394
- const part1 = firstPart.replace(unescapeRe, delim);
19395
- const part2 = parts.slice(1).join(delim).replace(unescapeRe, delim);
19396
- return [part1, part2];
19692
+ return result;
19397
19693
  }
19398
19694
  function validatePattern(type, pattern, responseDef) {
19399
19695
  if (pattern.trim() !== pattern) {
@@ -19402,8 +19698,7 @@ function validatePattern(type, pattern, responseDef) {
19402
19698
  scorm2004_errors.TYPE_MISMATCH
19403
19699
  );
19404
19700
  }
19405
- const subDelim1 = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : null;
19406
- const rawNodes = subDelim1 ? splitUnescaped(pattern, subDelim1) : [pattern];
19701
+ const rawNodes = responseDef.delimiter ? splitDelimited(pattern, responseDef.delimiter) : [pattern];
19407
19702
  for (const raw of rawNodes) {
19408
19703
  if (raw.trim() !== raw) {
19409
19704
  throw new Scorm2004ValidationError(
@@ -19415,20 +19710,14 @@ function validatePattern(type, pattern, responseDef) {
19415
19710
  if (type === "fill-in" && pattern === "") {
19416
19711
  return;
19417
19712
  }
19418
- const delim1 = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : null;
19419
- let nodes;
19420
- if (delim1) {
19421
- nodes = splitUnescaped(pattern, delim1);
19422
- } else {
19423
- nodes = [pattern];
19424
- }
19713
+ const nodes = responseDef.delimiter ? splitDelimited(pattern, responseDef.delimiter) : [pattern];
19425
19714
  if (!responseDef.delimiter && pattern.includes(",")) {
19426
19715
  throw new Scorm2004ValidationError(
19427
19716
  "cmi.interactions.n.correct_responses.n.pattern",
19428
19717
  scorm2004_errors.TYPE_MISMATCH
19429
19718
  );
19430
19719
  }
19431
- if (responseDef.unique || responseDef.duplicate === false) {
19720
+ if (type !== "numeric" && (responseDef.unique || responseDef.duplicate === false)) {
19432
19721
  const seen = new Set(nodes);
19433
19722
  if (seen.size !== nodes.length) {
19434
19723
  throw new Scorm2004ValidationError(
@@ -19460,8 +19749,7 @@ function validatePattern(type, pattern, responseDef) {
19460
19749
  scorm2004_errors.TYPE_MISMATCH
19461
19750
  );
19462
19751
  }
19463
- const delim = stripBrackets(delimBracketed);
19464
- const parts = value.split(new RegExp(`(?<!\\\\)${escapeRegex(delim)}`, "g")).map((n) => n.replace(new RegExp(`\\\\${escapeRegex(delim)}`, "g"), delim));
19752
+ const parts = splitDelimited(value, delimBracketed);
19465
19753
  if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
19466
19754
  throw new Scorm2004ValidationError(
19467
19755
  "cmi.interactions.n.correct_responses.n.pattern",
@@ -19478,15 +19766,17 @@ function validatePattern(type, pattern, responseDef) {
19478
19766
  for (const node of nodes) {
19479
19767
  switch (type) {
19480
19768
  case "numeric": {
19481
- const numDelim = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : ":";
19482
- const nums = node.split(numDelim);
19483
- if (nums.length < 1 || nums.length > 2) {
19484
- throw new Scorm2004ValidationError(
19485
- "cmi.interactions.n.correct_responses.n.pattern",
19486
- scorm2004_errors.TYPE_MISMATCH
19487
- );
19769
+ if (node === "") {
19770
+ const bracketedRange = nodes.length >= 2 && !!responseDef.delimiter && pattern.includes(responseDef.delimiter);
19771
+ if (!bracketedRange) {
19772
+ throw new Scorm2004ValidationError(
19773
+ "cmi.interactions.n.correct_responses.n.pattern",
19774
+ scorm2004_errors.TYPE_MISMATCH
19775
+ );
19776
+ }
19777
+ break;
19488
19778
  }
19489
- nums.forEach(checkSingle);
19779
+ checkSingle(node);
19490
19780
  break;
19491
19781
  }
19492
19782
  case "performance": {
@@ -19497,8 +19787,8 @@ function validatePattern(type, pattern, responseDef) {
19497
19787
  scorm2004_errors.TYPE_MISMATCH
19498
19788
  );
19499
19789
  }
19500
- const delim = stripBrackets(delimBracketed);
19501
- const parts = splitFirstUnescaped(node, delim);
19790
+ const record = stripResponsePrefixes(node);
19791
+ const parts = splitFirstDelimited(record, delimBracketed);
19502
19792
  if (parts.length !== 2) {
19503
19793
  throw new Scorm2004ValidationError(
19504
19794
  "cmi.interactions.n.correct_responses.n.pattern",
@@ -19506,7 +19796,7 @@ function validatePattern(type, pattern, responseDef) {
19506
19796
  );
19507
19797
  }
19508
19798
  const [part1, part2] = parts;
19509
- if (part1 === "" || part2 === "" || part1 === part2) {
19799
+ if (part1 === "" && part2 === "") {
19510
19800
  throw new Scorm2004ValidationError(
19511
19801
  "cmi.interactions.n.correct_responses.n.pattern",
19512
19802
  scorm2004_errors.TYPE_MISMATCH
@@ -22385,7 +22675,7 @@ class Scorm2004ResponseValidator {
22385
22675
  checkValidResponseType(CMIElement, response_type, value, interaction_type) {
22386
22676
  let nodes = [];
22387
22677
  if (response_type?.delimiter) {
22388
- nodes = String(value).split(response_type.delimiter);
22678
+ nodes = splitDelimited(String(value), response_type.delimiter);
22389
22679
  } else {
22390
22680
  nodes[0] = value;
22391
22681
  }
@@ -22507,22 +22797,30 @@ class Scorm2004ResponseValidator {
22507
22797
  nodes[i] = this.removeCorrectResponsePrefixes(CMIElement, nodes[i]);
22508
22798
  }
22509
22799
  if (response?.delimiter2) {
22510
- const values = nodes[i].split(response.delimiter2);
22800
+ const values = interaction_type === "performance" ? splitFirstDelimited(nodes[i], response.delimiter2) : splitDelimited(nodes[i], response.delimiter2);
22511
22801
  if (values.length === 2) {
22512
- const matches = values[0].match(formatRegex);
22513
- if (!matches) {
22802
+ if (interaction_type === "performance" && values[0] === "" && values[1] === "") {
22514
22803
  this.context.throwSCORMError(
22515
22804
  CMIElement,
22516
22805
  scorm2004_errors.TYPE_MISMATCH,
22517
22806
  `${interaction_type}: ${value}`
22518
22807
  );
22519
22808
  } else {
22520
- if (!response.format2 || !values[1].match(new RegExp(response.format2))) {
22809
+ const matches = values[0]?.match(formatRegex);
22810
+ if (!matches) {
22521
22811
  this.context.throwSCORMError(
22522
22812
  CMIElement,
22523
22813
  scorm2004_errors.TYPE_MISMATCH,
22524
22814
  `${interaction_type}: ${value}`
22525
22815
  );
22816
+ } else {
22817
+ if (!response.format2 || !values[1]?.match(new RegExp(response.format2))) {
22818
+ this.context.throwSCORMError(
22819
+ CMIElement,
22820
+ scorm2004_errors.TYPE_MISMATCH,
22821
+ `${interaction_type}: ${value}`
22822
+ );
22823
+ }
22526
22824
  }
22527
22825
  }
22528
22826
  } else {
@@ -22533,6 +22831,9 @@ class Scorm2004ResponseValidator {
22533
22831
  );
22534
22832
  }
22535
22833
  } else {
22834
+ if (interaction_type === "numeric" && nodes.length > 1 && nodes[i] === "" && !!response.delimiter && String(value).includes(response.delimiter)) {
22835
+ continue;
22836
+ }
22536
22837
  const matches = nodes[i].match(formatRegex);
22537
22838
  if (!matches && value !== "" || !matches && interaction_type === "true-false") {
22538
22839
  this.context.throwSCORMError(
@@ -22542,7 +22843,7 @@ class Scorm2004ResponseValidator {
22542
22843
  );
22543
22844
  } else {
22544
22845
  if (interaction_type === "numeric" && nodes.length > 1) {
22545
- if (Number(nodes[0]) > Number(nodes[1])) {
22846
+ if (nodes[0] !== "" && nodes[1] !== "" && Number(nodes[0]) > Number(nodes[1])) {
22546
22847
  this.context.throwSCORMError(
22547
22848
  CMIElement,
22548
22849
  scorm2004_errors.TYPE_MISMATCH,
@@ -24318,6 +24619,35 @@ class Scorm2004DataSerializer {
24318
24619
  }
24319
24620
  }
24320
24621
 
24622
+ const NO_DEFAULT_2004_ELEMENTS = /* @__PURE__ */ new Set([
24623
+ "cmi.suspend_data",
24624
+ "cmi.location",
24625
+ "cmi.scaled_passing_score",
24626
+ "cmi.max_time_allowed",
24627
+ "cmi.completion_threshold",
24628
+ "cmi.progress_measure",
24629
+ "cmi.score.scaled",
24630
+ "cmi.score.raw",
24631
+ "cmi.score.min",
24632
+ "cmi.score.max",
24633
+ "cmi.objectives.N.score.scaled",
24634
+ "cmi.objectives.N.score.raw",
24635
+ "cmi.objectives.N.score.min",
24636
+ "cmi.objectives.N.score.max",
24637
+ "cmi.objectives.N.progress_measure",
24638
+ "cmi.objectives.N.description",
24639
+ "cmi.interactions.N.weighting",
24640
+ "cmi.interactions.N.type",
24641
+ "cmi.interactions.N.timestamp",
24642
+ "cmi.interactions.N.result",
24643
+ "cmi.interactions.N.latency",
24644
+ "cmi.interactions.N.learner_response",
24645
+ "cmi.interactions.N.description",
24646
+ "cmi.comments_from_learner.N.timestamp"
24647
+ ]);
24648
+ function normalizeCMIIndices(CMIElement) {
24649
+ return CMIElement.replace(/\.\d+(?=\.|$)/g, ".N");
24650
+ }
24321
24651
  class Scorm2004API extends BaseAPI {
24322
24652
  _version = "1.0";
24323
24653
  _sequencing;
@@ -24843,6 +25173,39 @@ class Scorm2004API extends BaseAPI {
24843
25173
  getCMIValue(CMIElement) {
24844
25174
  return this._commonGetCMIValue("GetValue", true, CMIElement);
24845
25175
  }
25176
+ /**
25177
+ * Raises 403 (VALUE_NOT_INITIALIZED) when an implemented, no-default element is
25178
+ * read before it has ever been set, per IEEE 1484.11.2 / SCORM 2004 RTE.
25179
+ *
25180
+ * Invoked by BaseAPI.getValue after an otherwise-successful resolution, so it
25181
+ * only sees values the data model already returned cleanly. The decision is
25182
+ * deliberately gated to this public boundary (never the getters) to keep
25183
+ * serialization/commit/rollup — which read the getters directly — unaffected.
25184
+ *
25185
+ * A 403 is raised only when all hold:
25186
+ * - the resolved value is "" — any non-empty value is by definition set,
25187
+ * including values written by internal sequencing/activity-tree paths that
25188
+ * bypass _commonSetCMIValue (those only ever write non-empty values);
25189
+ * - the element was never set — _setCMIElements records every successful
25190
+ * SetValue, loadFromJSON, and global-objective restore (all route through
25191
+ * _commonSetCMIValue), so an explicit SetValue(x, "") still reads back as
25192
+ * "" with code 0; and
25193
+ * - the element has no spec-defined default ({@link NO_DEFAULT_2004_ELEMENTS}).
25194
+ *
25195
+ * @param {string} CMIElement
25196
+ * @param {*} returnValue - the value getCMIValue resolved
25197
+ * @protected
25198
+ */
25199
+ checkUninitializedGet(CMIElement, returnValue) {
25200
+ if (returnValue !== "") return;
25201
+ if (this._setCMIElements.has(CMIElement)) return;
25202
+ if (!NO_DEFAULT_2004_ELEMENTS.has(normalizeCMIIndices(CMIElement))) return;
25203
+ this.throwSCORMError(
25204
+ CMIElement,
25205
+ this._error_codes.VALUE_NOT_INITIALIZED ?? 403,
25206
+ `The data model element passed to GetValue (${CMIElement}) has not been initialized.`
25207
+ );
25208
+ }
24846
25209
  /**
24847
25210
  * Returns the message that corresponds to errorNumber.
24848
25211
  * @param {(string|number)} errorNumber
@@ -24889,9 +25252,10 @@ class Scorm2004API extends BaseAPI {
24889
25252
  /**
24890
25253
  * Attempts to store the data to the LMS
24891
25254
  * @param {boolean} terminateCommit
25255
+ * @param {CommitTrigger} [trigger] - What initiated the commit
24892
25256
  * @return {ResultObject}
24893
25257
  */
24894
- storeData(terminateCommit) {
25258
+ storeData(terminateCommit, trigger) {
24895
25259
  if (terminateCommit) {
24896
25260
  if (this.cmi.mode === "normal") {
24897
25261
  if (this.cmi.credit === "credit") {
@@ -24939,7 +25303,8 @@ class Scorm2004API extends BaseAPI {
24939
25303
  const result = this.processHttpRequest(
24940
25304
  this.settings.lmsCommitUrl,
24941
25305
  commitObject,
24942
- terminateCommit
25306
+ terminateCommit,
25307
+ trigger
24943
25308
  );
24944
25309
  if (navRequest && result.navRequest !== void 0 && result.navRequest !== "" && typeof result.navRequest === "string") {
24945
25310
  const parsed = parseNavigationRequest(result.navRequest);