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
@@ -210,6 +210,15 @@ function parseNavigationRequest(navRequest) {
210
210
  };
211
211
  }
212
212
 
213
+ const appendQueryParam = (url, name, value) => {
214
+ const fragmentIndex = url.indexOf("#");
215
+ const baseUrl = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
216
+ const fragment = fragmentIndex === -1 ? "" : url.slice(fragmentIndex);
217
+ const separator = baseUrl.includes("?") ? baseUrl.endsWith("?") || baseUrl.endsWith("&") ? "" : "&" : "?";
218
+ const queryParam = `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`;
219
+ return `${baseUrl}${separator}${queryParam}${fragment}`;
220
+ };
221
+
213
222
  class BaseCMI {
214
223
  /**
215
224
  * Flag used during JSON serialization to allow getter access without initialization checks.
@@ -583,7 +592,10 @@ const scorm12_errors = {
583
592
  INVALID_SET_VALUE: 402,
584
593
  READ_ONLY_ELEMENT: 403,
585
594
  TYPE_MISMATCH: 405,
586
- VALUE_OUT_OF_RANGE: 407};
595
+ // SCORM 1.2 has no out-of-range error code; an out-of-range value is reported
596
+ // as 405 (incorrect data type), per the SCORM 1.2 RTE error table and the ADL
597
+ // 1.2 CTS (DataModelValidator.checkScoreDecimal failure -> CMICategory 405).
598
+ VALUE_OUT_OF_RANGE: 405};
587
599
  const scorm2004_errors = {
588
600
  ...global_errors,
589
601
  INITIALIZATION_FAILED: 102,
@@ -729,6 +741,7 @@ const DefaultSettings = {
729
741
  xhrWithCredentials: false,
730
742
  fetchMode: "cors",
731
743
  asyncModeBeaconBehavior: "never",
744
+ includeCommitSequence: false,
732
745
  responseHandler: async function(response) {
733
746
  if (typeof response !== "undefined") {
734
747
  let httpResult = null;
@@ -940,6 +953,11 @@ const scorm2004_regex = {
940
953
  progress_range: "0#1"
941
954
  };
942
955
 
956
+ const PERFORMANCE_STEP_NAME = "^$|" + scorm2004_regex.CMIShortIdentifier;
957
+ const PERFORMANCE_CHARACTERSTRING = "(?![\\s\\S]*(?:\\[,\\]|\\[\\.\\]|\\[:\\]))[\\s\\S]{1,250}";
958
+ const PERFORMANCE_NUMERIC_RANGE = "(?:-?\\d+(?:\\.\\d+)?)?\\[:\\](?:-?\\d+(?:\\.\\d+)?)?";
959
+ const CR_PERFORMANCE_STEP_ANSWER = "^(?:|" + PERFORMANCE_NUMERIC_RANGE + "|" + PERFORMANCE_CHARACTERSTRING + ")$";
960
+ const LR_PERFORMANCE_STEP_ANSWER = "^(?:|" + PERFORMANCE_CHARACTERSTRING + ")$";
943
961
  const LearnerResponses = {
944
962
  "true-false": {
945
963
  format: "^true$|^false$",
@@ -974,8 +992,8 @@ const LearnerResponses = {
974
992
  unique: false
975
993
  },
976
994
  performance: {
977
- format: "^$|" + scorm2004_regex.CMIShortIdentifier,
978
- format2: scorm2004_regex.CMIDecimal + "|^$|" + scorm2004_regex.CMIShortIdentifier,
995
+ format: PERFORMANCE_STEP_NAME,
996
+ format2: LR_PERFORMANCE_STEP_ANSWER,
979
997
  max: 250,
980
998
  delimiter: "[,]",
981
999
  delimiter2: "[.]",
@@ -1051,10 +1069,10 @@ const CorrectResponses = {
1051
1069
  delimiter2: "[.]",
1052
1070
  unique: false,
1053
1071
  duplicate: false,
1054
- // step_name must be a non-empty short identifier
1055
- format: scorm2004_regex.CMIShortIdentifier,
1056
- // step_answer may be short identifier or numeric range (<decimal>[:<decimal>])
1057
- format2: `^(${scorm2004_regex.CMIShortIdentifier})$|^(?:\\d+(?:\\.\\d+)?(?::\\d+(?:\\.\\d+)?)?)$`
1072
+ // step_name: optional short_identifier_type
1073
+ format: PERFORMANCE_STEP_NAME,
1074
+ // step_answer: optional characterstring (spaces allowed) or numeric range
1075
+ format2: CR_PERFORMANCE_STEP_ANSWER
1058
1076
  },
1059
1077
  sequencing: {
1060
1078
  max: 36,
@@ -1514,7 +1532,7 @@ class ScheduledCommit {
1514
1532
  wrapper() {
1515
1533
  if (!this._cancelled) {
1516
1534
  if (this._API.isInitialized()) {
1517
- (async () => await this._API.commit(this._callback))();
1535
+ (async () => await this._API.commit(this._callback, false, "autocommit"))();
1518
1536
  }
1519
1537
  }
1520
1538
  }
@@ -4859,6 +4877,7 @@ class ActivityDeliveryService {
4859
4877
  }
4860
4878
 
4861
4879
  class AsynchronousHttpService {
4880
+ reportsRequestCompletion = true;
4862
4881
  settings;
4863
4882
  error_codes;
4864
4883
  /**
@@ -4883,10 +4902,20 @@ class AsynchronousHttpService {
4883
4902
  * @param {boolean} immediate - Whether to send the request immediately without waiting
4884
4903
  * @param {Function} apiLog - Function to log API messages with appropriate levels
4885
4904
  * @param {Function} processListeners - Function to trigger event listeners for commit events
4905
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
4906
+ * @param {Function} onRequestComplete - Callback invoked after the background request settles
4886
4907
  * @return {ResultObject} - Immediate optimistic success result
4887
4908
  */
4888
- processHttpRequest(url, params, immediate = false, apiLog, processListeners) {
4889
- this._performAsyncRequest(url, params, immediate, apiLog, processListeners);
4909
+ processHttpRequest(url, params, immediate = false, apiLog, processListeners, metadata, onRequestComplete) {
4910
+ this._performAsyncRequest(
4911
+ url,
4912
+ params,
4913
+ immediate,
4914
+ apiLog,
4915
+ processListeners,
4916
+ metadata,
4917
+ onRequestComplete
4918
+ );
4890
4919
  return {
4891
4920
  result: global_constants.SCORM_TRUE,
4892
4921
  errorCode: 0
@@ -4899,11 +4928,14 @@ class AsynchronousHttpService {
4899
4928
  * @param {boolean} immediate - Whether this is an immediate request
4900
4929
  * @param apiLog - Function to log API messages
4901
4930
  * @param {Function} processListeners - Function to process event listeners
4931
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
4932
+ * @param {Function} onRequestComplete - Callback invoked after the request settles
4902
4933
  * @private
4903
4934
  */
4904
- async _performAsyncRequest(url, params, immediate, apiLog, processListeners) {
4935
+ async _performAsyncRequest(url, params, immediate, apiLog, processListeners, metadata, onRequestComplete) {
4905
4936
  try {
4906
- const processedParams = this.settings.requestHandler(params);
4937
+ const handledParams = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
4938
+ const processedParams = handledParams;
4907
4939
  let response;
4908
4940
  if (immediate && this.settings.asyncModeBeaconBehavior !== "never") {
4909
4941
  response = await this.performBeacon(url, processedParams);
@@ -4919,7 +4951,9 @@ class AsynchronousHttpService {
4919
4951
  } catch (e) {
4920
4952
  const message = e instanceof Error ? e.message : String(e);
4921
4953
  apiLog("processHttpRequest", `Async request failed: ${message}`, LogLevelEnum.ERROR);
4922
- processListeners("CommitError");
4954
+ processListeners("CommitError", void 0, this.error_codes.GENERAL_COMMIT_FAILURE || 391);
4955
+ } finally {
4956
+ onRequestComplete?.();
4923
4957
  }
4924
4958
  }
4925
4959
  /**
@@ -5062,10 +5096,13 @@ class CMIValueAccessService {
5062
5096
  }
5063
5097
  /**
5064
5098
  * Gets the appropriate error code for undefined data model elements.
5065
- * SCORM 2004 uses UNDEFINED_DATA_MODEL, SCORM 1.2 uses GENERAL.
5099
+ * Both SCORM 2004 and SCORM 1.2 use UNDEFINED_DATA_MODEL (401): an
5100
+ * unrecognized element is "Not implemented", not a general exception. SCORM
5101
+ * 1.2 previously returned GENERAL (101) here, which is non-conformant — the
5102
+ * ADL 1.2 CTS and the SCORM 1.2 RTE spec expect 401 for an unknown element.
5066
5103
  */
5067
- getUndefinedDataModelErrorCode(scorm2004) {
5068
- return scorm2004 ? getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL") : getErrorCode(this.context.errorCodes, "GENERAL");
5104
+ getUndefinedDataModelErrorCode() {
5105
+ return getErrorCode(this.context.errorCodes, "UNDEFINED_DATA_MODEL");
5069
5106
  }
5070
5107
  /**
5071
5108
  * Sets a value on a CMI element path
@@ -5093,7 +5130,7 @@ class CMIValueAccessService {
5093
5130
  let returnValue = global_constants.SCORM_FALSE;
5094
5131
  let foundFirstIndex = false;
5095
5132
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5096
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5133
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5097
5134
  for (let idx = 0; idx < structure.length; idx++) {
5098
5135
  const attribute = structure[idx];
5099
5136
  if (idx === structure.length - 1) {
@@ -5163,12 +5200,13 @@ class CMIValueAccessService {
5163
5200
  );
5164
5201
  return "";
5165
5202
  }
5203
+ this.context.setLastErrorCode("0");
5166
5204
  const structure = CMIElement.split(".");
5167
5205
  let refObject = this.context.getDataModel();
5168
5206
  let attribute = null;
5169
5207
  const uninitializedErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) has not been initialized.`;
5170
5208
  const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`;
5171
- const invalidErrorCode = this.getUndefinedDataModelErrorCode(scorm2004);
5209
+ const invalidErrorCode = this.getUndefinedDataModelErrorCode();
5172
5210
  for (let idx = 0; idx < structure.length; idx++) {
5173
5211
  attribute = structure[idx];
5174
5212
  const validationResult = this.validateGetAttribute(
@@ -5363,7 +5401,19 @@ class CMIValueAccessService {
5363
5401
  if (!scorm2004) {
5364
5402
  if (isFinalAttribute) {
5365
5403
  if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) {
5366
- this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
5404
+ if (attribute === "_children") {
5405
+ this.context.throwSCORMError(
5406
+ CMIElement,
5407
+ getErrorCode(this.context.errorCodes, "CHILDREN_ERROR")
5408
+ );
5409
+ } else if (attribute === "_count") {
5410
+ this.context.throwSCORMError(
5411
+ CMIElement,
5412
+ getErrorCode(this.context.errorCodes, "COUNT_ERROR")
5413
+ );
5414
+ } else {
5415
+ this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
5416
+ }
5367
5417
  return { error: true };
5368
5418
  }
5369
5419
  }
@@ -5868,8 +5918,9 @@ class EventService {
5868
5918
  * @param {string} functionName - The name of the function that triggered the event
5869
5919
  * @param {string} CMIElement - The CMI element that was affected
5870
5920
  * @param {any} value - The value that was set
5921
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
5871
5922
  */
5872
- processListeners(functionName, CMIElement, value) {
5923
+ processListeners(functionName, CMIElement, value, context) {
5873
5924
  this.apiLog(functionName, value, LogLevelEnum.INFO, CMIElement);
5874
5925
  const listeners = this.listenerMap.get(functionName);
5875
5926
  if (!listeners) return;
@@ -5894,9 +5945,17 @@ class EventService {
5894
5945
  if (functionName.startsWith("Sequence")) {
5895
5946
  listener.callback(value);
5896
5947
  } else if (functionName === "CommitError") {
5897
- listener.callback(value);
5948
+ if (context !== void 0) {
5949
+ listener.callback(value, context);
5950
+ } else {
5951
+ listener.callback(value);
5952
+ }
5898
5953
  } else if (functionName === "CommitSuccess") {
5899
- listener.callback();
5954
+ if (context !== void 0) {
5955
+ listener.callback(context);
5956
+ } else {
5957
+ listener.callback();
5958
+ }
5900
5959
  } else {
5901
5960
  listener.callback(CMIElement, value);
5902
5961
  }
@@ -6030,16 +6089,19 @@ class OfflineStorageService {
6030
6089
  * Store commit data offline
6031
6090
  * @param {string} courseId - Identifier for the course
6032
6091
  * @param {CommitObject} commitData - The data to store offline
6092
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6033
6093
  * @returns {ResultObject} - Result of the storage operation
6034
6094
  */
6035
- storeOffline(courseId, commitData) {
6095
+ storeOffline(courseId, commitData, metadata) {
6036
6096
  try {
6037
6097
  const queueItem = {
6038
6098
  id: `${courseId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
6039
6099
  courseId,
6040
6100
  timestamp: Date.now(),
6041
6101
  data: commitData,
6042
- syncAttempts: 0
6102
+ syncAttempts: 0,
6103
+ ...metadata?.isTerminateCommit !== void 0 ? { isTerminateCommit: metadata.isTerminateCommit } : {},
6104
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6043
6105
  };
6044
6106
  const currentQueue = this.getFromStorage(this.syncQueue) || [];
6045
6107
  currentQueue.push(queueItem);
@@ -6118,7 +6180,10 @@ class OfflineStorageService {
6118
6180
  continue;
6119
6181
  }
6120
6182
  try {
6121
- const syncResult = await this.sendDataToLMS(item.data);
6183
+ const syncResult = await this.sendDataToLMS(item.data, {
6184
+ ...item.isTerminateCommit !== void 0 ? { isTerminateCommit: item.isTerminateCommit } : {},
6185
+ ...item.sequence !== void 0 ? { sequence: item.sequence } : {}
6186
+ });
6122
6187
  if (syncResult.result === true || syncResult.result === global_constants.SCORM_TRUE) {
6123
6188
  this.apiLog(
6124
6189
  "OfflineStorageService",
@@ -6165,17 +6230,25 @@ class OfflineStorageService {
6165
6230
  /**
6166
6231
  * Send data to the LMS when online
6167
6232
  * @param {CommitObject} data - The data to send to the LMS
6233
+ * @param {OfflineCommitMetadata} metadata - Metadata captured with the original commit
6168
6234
  * @returns {Promise<ResultObject>} - Result of the sync operation
6169
6235
  */
6170
- async sendDataToLMS(data) {
6171
- if (!this.settings.lmsCommitUrl) {
6236
+ async sendDataToLMS(data, metadata) {
6237
+ const configuredCommitUrl = this.settings.lmsCommitUrl;
6238
+ if (!configuredCommitUrl) {
6172
6239
  return {
6173
6240
  result: global_constants.SCORM_FALSE,
6174
6241
  errorCode: this.error_codes.GENERAL || 101
6175
6242
  };
6176
6243
  }
6177
6244
  try {
6178
- const processedData = this.settings.requestHandler(data);
6245
+ const lmsCommitUrl = String(configuredCommitUrl);
6246
+ const processedData = this.settings.requestHandler(data, {
6247
+ isTerminateCommit: metadata?.isTerminateCommit ?? false,
6248
+ trigger: "offline-replay",
6249
+ ...metadata?.sequence !== void 0 ? { sequence: metadata.sequence } : {}
6250
+ });
6251
+ const requestUrl = metadata?.isTerminateCommit && this.settings.terminateCommitParam ? appendQueryParam(lmsCommitUrl, this.settings.terminateCommitParam, "true") : lmsCommitUrl;
6179
6252
  const init = {
6180
6253
  method: "POST",
6181
6254
  mode: this.settings.fetchMode,
@@ -6188,7 +6261,7 @@ class OfflineStorageService {
6188
6261
  if (this.settings.xhrWithCredentials) {
6189
6262
  init.credentials = "include";
6190
6263
  }
6191
- const response = await fetch(this.settings.lmsCommitUrl, init);
6264
+ const response = await fetch(requestUrl, init);
6192
6265
  const result = typeof this.settings.responseHandler === "function" ? await this.settings.responseHandler(response) : await response.json();
6193
6266
  if (response.status >= 200 && response.status <= 299 && (result.result === true || result.result === global_constants.SCORM_TRUE)) {
6194
6267
  if (!Object.hasOwnProperty.call(result, "errorCode")) {
@@ -15153,6 +15226,8 @@ class SynchronousHttpService {
15153
15226
  * @param {boolean} immediate - Whether this is a termination commit (use sendBeacon)
15154
15227
  * @param {Function} _apiLog - Function to log API messages (unused in synchronous mode - errors returned directly)
15155
15228
  * @param {Function} _processListeners - Function to trigger event listeners (unused in synchronous mode - no async events)
15229
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15230
+ * @param {Function} _onRequestComplete - Completion callback (unused because requests settle before return)
15156
15231
  * @return {ResultObject} - The result of the request (synchronous)
15157
15232
  *
15158
15233
  * @remarks
@@ -15162,21 +15237,23 @@ class SynchronousHttpService {
15162
15237
  * - No async events need to be triggered (CommitSuccess/CommitError) since results are synchronous
15163
15238
  * - AsynchronousHttpService uses these parameters to handle background request results
15164
15239
  */
15165
- processHttpRequest(url, params, immediate = false, _apiLog, _processListeners) {
15240
+ processHttpRequest(url, params, immediate = false, _apiLog, _processListeners, metadata, _onRequestComplete) {
15166
15241
  if (immediate) {
15167
- return this._handleImmediateRequest(url, params);
15242
+ return this._handleImmediateRequest(url, params, metadata);
15168
15243
  }
15169
- return this._performSyncXHR(url, params);
15244
+ return this._performSyncXHR(url, params, metadata);
15170
15245
  }
15171
15246
  /**
15172
15247
  * Handles an immediate request using sendBeacon
15173
15248
  * @param {string} url - The URL to send the request to
15174
15249
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15250
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15175
15251
  * @return {ResultObject} - The result based on beacon success
15176
15252
  * @private
15177
15253
  */
15178
- _handleImmediateRequest(url, params) {
15179
- const requestPayload = this.settings.requestHandler(params) ?? params;
15254
+ _handleImmediateRequest(url, params, metadata) {
15255
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15256
+ const requestPayload = handledPayload ?? params;
15180
15257
  const { body } = this._prepareRequestBody(requestPayload);
15181
15258
  const beaconSuccess = navigator.sendBeacon(
15182
15259
  url,
@@ -15191,11 +15268,13 @@ class SynchronousHttpService {
15191
15268
  * Performs a synchronous XMLHttpRequest
15192
15269
  * @param {string} url - The URL to send the request to
15193
15270
  * @param {CommitObject|StringKeyMap|Array} params - The parameters to include in the request
15271
+ * @param {CommitMetadata} metadata - Metadata describing the captured commit
15194
15272
  * @return {ResultObject} - The result of the request
15195
15273
  * @private
15196
15274
  */
15197
- _performSyncXHR(url, params) {
15198
- const requestPayload = this.settings.requestHandler(params) ?? params;
15275
+ _performSyncXHR(url, params, metadata) {
15276
+ const handledPayload = metadata === void 0 ? this.settings.requestHandler(params) : this.settings.requestHandler(params, metadata);
15277
+ const requestPayload = handledPayload ?? params;
15199
15278
  const { body, contentType } = this._prepareRequestBody(requestPayload);
15200
15279
  const xhr = new XMLHttpRequest();
15201
15280
  xhr.open("POST", url, false);
@@ -15274,9 +15353,15 @@ class ValidationService {
15274
15353
  * @param {number} invalidTypeCode - The error code for invalid type
15275
15354
  * @param {number} invalidRangeCode - The error code for invalid range
15276
15355
  * @param {typeof BaseScormValidationError} errorClass - The error class to use for validation errors
15356
+ * @param {boolean} allowEmptyString - When true, an empty string is accepted (clears the value).
15357
+ * SCORM 1.2 score elements may be blank per the ADL 1.2 CTS
15358
+ * (DataModelValidator.checkScoreDecimal treats blank as valid).
15277
15359
  * @return {boolean} - True if validation passes, throws an error otherwise
15278
15360
  */
15279
- validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass) {
15361
+ validateScore(CMIElement, value, decimalRegex, scoreRange, invalidTypeCode, invalidRangeCode, errorClass, allowEmptyString = false) {
15362
+ if (allowEmptyString && value === "") {
15363
+ return true;
15364
+ }
15280
15365
  return checkValidFormat(CMIElement, value, decimalRegex, invalidTypeCode, errorClass) && (!scoreRange || checkValidRange(CMIElement, value, scoreRange, invalidRangeCode, errorClass));
15281
15366
  }
15282
15367
  /**
@@ -15350,6 +15435,21 @@ class BaseAPI {
15350
15435
  _offlineStorageService;
15351
15436
  _cmiValueAccessService;
15352
15437
  _courseId = "";
15438
+ _pendingCommitCount = 0;
15439
+ /**
15440
+ * Monotonic sequence for commits captured by this API instance. It is
15441
+ * intentionally not reset by reset().
15442
+ */
15443
+ _commitSequence = 0;
15444
+ _commitSettleWaiters = [];
15445
+ /**
15446
+ * Canonical paths of every CMI element that has been explicitly assigned a
15447
+ * value via SetValue / loadFromJSON (both funnel through _commonSetCMIValue).
15448
+ * Used by standards that must tell "implemented but never set" apart from a
15449
+ * legitimately empty value when answering GetValue (SCORM 2004 error 403).
15450
+ * Cleared on reset so a fresh SCO attempt starts with nothing "set".
15451
+ */
15452
+ _setCMIElements = /* @__PURE__ */ new Set();
15353
15453
  /**
15354
15454
  * Constructor for Base API class. Sets some shared API fields, as well as
15355
15455
  * sets up options for the API.
@@ -15535,6 +15635,7 @@ class BaseAPI {
15535
15635
  this.lastErrorCode = "0";
15536
15636
  this._eventService.reset();
15537
15637
  this.startingData = {};
15638
+ this._setCMIElements.clear();
15538
15639
  if (this._offlineStorageService) {
15539
15640
  this._offlineStorageService.updateSettings(this.settings);
15540
15641
  if (settings?.courseId) {
@@ -15619,6 +15720,52 @@ class BaseAPI {
15619
15720
  this._loggingService?.setLogHandler(settings.onLogMessage);
15620
15721
  }
15621
15722
  }
15723
+ /**
15724
+ * Gets the number of captured commit requests that have not yet settled.
15725
+ *
15726
+ * @return {number} The number of in-flight commits
15727
+ */
15728
+ get pendingCommitCount() {
15729
+ return this._pendingCommitCount;
15730
+ }
15731
+ /**
15732
+ * Resolves when all currently in-flight commits have settled. A timeout is
15733
+ * best-effort: the promise resolves when it elapses even if commits remain,
15734
+ * and callers can inspect pendingCommitCount afterward to detect that case.
15735
+ *
15736
+ * @param {Object} [options] - Settle options
15737
+ * @param {number} [options.timeoutMs] - Maximum time to wait in milliseconds
15738
+ * @return {Promise<void>} A promise that resolves after the drain or timeout
15739
+ */
15740
+ whenCommitsSettled(options) {
15741
+ if (this._pendingCommitCount === 0) {
15742
+ return Promise.resolve();
15743
+ }
15744
+ return new Promise((resolve) => {
15745
+ const waiter = { resolve };
15746
+ if (options?.timeoutMs !== void 0) {
15747
+ waiter.timeoutId = setTimeout(() => {
15748
+ const waiterIndex = this._commitSettleWaiters.indexOf(waiter);
15749
+ if (waiterIndex === -1) {
15750
+ return;
15751
+ }
15752
+ this._commitSettleWaiters.splice(waiterIndex, 1);
15753
+ resolve();
15754
+ }, options.timeoutMs);
15755
+ }
15756
+ this._commitSettleWaiters.push(waiter);
15757
+ });
15758
+ }
15759
+ /** Resolve and clear every waiter after the pending count reaches zero. */
15760
+ _flushCommitSettleWaiters() {
15761
+ const waiters = this._commitSettleWaiters.splice(0);
15762
+ for (const waiter of waiters) {
15763
+ if (waiter.timeoutId !== void 0) {
15764
+ clearTimeout(waiter.timeoutId);
15765
+ }
15766
+ waiter.resolve();
15767
+ }
15768
+ }
15622
15769
  /**
15623
15770
  * Terminates the current run of the API
15624
15771
  * @param {string} callbackName
@@ -15639,7 +15786,7 @@ class BaseAPI {
15639
15786
  } else {
15640
15787
  stateCheckPassed = true;
15641
15788
  this.processListeners("BeforeTerminate");
15642
- const result = this.storeData(true);
15789
+ const result = this.storeData(true, "terminate");
15643
15790
  if ((result.errorCode ?? 0) > 0) {
15644
15791
  if (result.errorMessage) {
15645
15792
  this.apiLog(
@@ -15691,6 +15838,9 @@ class BaseAPI {
15691
15838
  } catch (e) {
15692
15839
  returnValue = this.handleValueAccessException(CMIElement, e, returnValue);
15693
15840
  }
15841
+ if (this.lastErrorCode === "0") {
15842
+ this.checkUninitializedGet(CMIElement, returnValue);
15843
+ }
15694
15844
  this.processListeners(callbackName, CMIElement);
15695
15845
  }
15696
15846
  this.apiLog(callbackName, ": returned: " + returnValue, LogLevelEnum.INFO, CMIElement);
@@ -15700,6 +15850,10 @@ class BaseAPI {
15700
15850
  if (this.lastErrorCode === "0") {
15701
15851
  this.clearSCORMError(returnValue);
15702
15852
  }
15853
+ const rawReturn = returnValue;
15854
+ if (typeof rawReturn === "number" || typeof rawReturn === "boolean") {
15855
+ return String(rawReturn);
15856
+ }
15703
15857
  return returnValue;
15704
15858
  }
15705
15859
  /**
@@ -15752,9 +15906,10 @@ class BaseAPI {
15752
15906
  * Orders LMS to store all content parameters
15753
15907
  * @param {string} callbackName
15754
15908
  * @param {boolean} checkTerminated
15909
+ * @param {CommitTrigger} trigger - What initiated the commit
15755
15910
  * @return {string}
15756
15911
  */
15757
- commit(callbackName, checkTerminated = false) {
15912
+ commit(callbackName, checkTerminated = false, trigger = "manual") {
15758
15913
  this.clearScheduledCommit();
15759
15914
  let returnValue = global_constants.SCORM_TRUE;
15760
15915
  if (this.isNotInitialized()) {
@@ -15766,8 +15921,9 @@ class BaseAPI {
15766
15921
  this.throwSCORMError("api", errorCode);
15767
15922
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
15768
15923
  } else {
15769
- const result = this.storeData(false);
15770
- if ((result.errorCode ?? 0) > 0) {
15924
+ const result = this.storeData(false, trigger);
15925
+ const errorCode = result.errorCode ?? 0;
15926
+ if (errorCode > 0) {
15771
15927
  if (result.errorMessage) {
15772
15928
  this.apiLog(
15773
15929
  "commit",
@@ -15782,12 +15938,12 @@ class BaseAPI {
15782
15938
  LogLevelEnum.DEBUG
15783
15939
  );
15784
15940
  }
15785
- this.throwSCORMError("api", result.errorCode);
15941
+ this.throwSCORMError("api", errorCode);
15786
15942
  }
15787
15943
  const resultValue = result?.result ?? global_constants.SCORM_FALSE;
15788
15944
  returnValue = typeof resultValue === "boolean" ? String(resultValue) : resultValue;
15789
15945
  this.apiLog(callbackName, " Result: " + returnValue, LogLevelEnum.DEBUG, "HttpRequest");
15790
- if (checkTerminated) this.lastErrorCode = "0";
15946
+ if (checkTerminated && errorCode === 0) this.lastErrorCode = "0";
15791
15947
  this.processListeners(callbackName);
15792
15948
  if (this.settings.enableOfflineSupport && this._offlineStorageService && this._offlineStorageService.isDeviceOnline() && this._courseId) {
15793
15949
  this._offlineStorageService.hasPendingOfflineData(this._courseId).then((hasPendingData) => {
@@ -15996,7 +16152,16 @@ class BaseAPI {
15996
16152
  * @return {string}
15997
16153
  */
15998
16154
  _commonSetCMIValue(methodName, scorm2004, CMIElement, value) {
15999
- return this._cmiValueAccessService.setCMIValue(methodName, scorm2004, CMIElement, value);
16155
+ const result = this._cmiValueAccessService.setCMIValue(
16156
+ methodName,
16157
+ scorm2004,
16158
+ CMIElement,
16159
+ value
16160
+ );
16161
+ if (result === global_constants.SCORM_TRUE) {
16162
+ this._setCMIElements.add(CMIElement);
16163
+ }
16164
+ return result;
16000
16165
  }
16001
16166
  /**
16002
16167
  * Gets a value from the CMI Object.
@@ -16010,6 +16175,18 @@ class BaseAPI {
16010
16175
  _commonGetCMIValue(methodName, scorm2004, CMIElement) {
16011
16176
  return this._cmiValueAccessService.getCMIValue(methodName, scorm2004, CMIElement);
16012
16177
  }
16178
+ /**
16179
+ * Hook invoked by getValue after a successful resolution. Standards that must
16180
+ * distinguish "implemented but never set, no default value" from a
16181
+ * legitimately empty value override this to raise VALUE_NOT_INITIALIZED.
16182
+ * Default is a no-op, so SCORM 1.2 / AICC keep returning "" with code 0.
16183
+ *
16184
+ * @param {string} _CMIElement - the element that was read
16185
+ * @param {any} _returnValue - the value getCMIValue resolved
16186
+ * @protected
16187
+ */
16188
+ checkUninitializedGet(_CMIElement, _returnValue) {
16189
+ }
16013
16190
  /**
16014
16191
  * Returns true if the API's current state is STATE_INITIALIZED
16015
16192
  *
@@ -16092,9 +16269,14 @@ class BaseAPI {
16092
16269
  * @param {string} functionName - The name of the function/event that occurred
16093
16270
  * @param {string} CMIElement - Optional CMI element involved in the event
16094
16271
  * @param {any} value - Optional value associated with the event
16272
+ * @param {CommitEventContext} context - Optional context for commit lifecycle events
16095
16273
  */
16096
- processListeners(functionName, CMIElement, value) {
16097
- this._eventService.processListeners(functionName, CMIElement, value);
16274
+ processListeners(functionName, CMIElement, value, context) {
16275
+ if (context !== void 0) {
16276
+ this._eventService.processListeners(functionName, CMIElement, value, context);
16277
+ } else {
16278
+ this._eventService.processListeners(functionName, CMIElement, value);
16279
+ }
16098
16280
  }
16099
16281
  /**
16100
16282
  * Throws a SCORM error with the specified error number and optional message.
@@ -16227,36 +16409,106 @@ class BaseAPI {
16227
16409
  * @param {string} url - The URL to send the request to
16228
16410
  * @param {CommitObject | StringKeyMap | Array<any>} params - The parameters to send
16229
16411
  * @param {boolean} immediate - Whether to send the request immediately without waiting
16412
+ * @param {CommitTrigger} [trigger] - What initiated the commit
16230
16413
  * @returns {ResultObject} - The result of the request
16231
16414
  */
16232
- processHttpRequest(url, params, immediate = false) {
16233
- if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16234
- this.apiLog(
16235
- "processHttpRequest",
16236
- "Device is offline, storing data locally",
16237
- LogLevelEnum.INFO
16238
- );
16239
- if (params && typeof params === "object" && "cmi" in params) {
16240
- return this._offlineStorageService.storeOffline(this._courseId, params);
16241
- } else {
16415
+ processHttpRequest(url, params, immediate = false, trigger) {
16416
+ const sequence = ++this._commitSequence;
16417
+ this._pendingCommitCount += 1;
16418
+ let settled = false;
16419
+ let completionDeferred = false;
16420
+ const settle = () => {
16421
+ if (settled) {
16422
+ return;
16423
+ }
16424
+ settled = true;
16425
+ this._pendingCommitCount -= 1;
16426
+ if (this._pendingCommitCount === 0) {
16427
+ this._flushCommitSettleWaiters();
16428
+ }
16429
+ };
16430
+ try {
16431
+ const resolvedTrigger = trigger ?? (immediate ? "terminate" : "manual");
16432
+ let finalParams = params;
16433
+ if (immediate && this.settings.terminateCommitPayloadField) {
16434
+ const field = this.settings.terminateCommitPayloadField;
16435
+ if (Array.isArray(finalParams)) {
16436
+ finalParams = [...finalParams, `${encodeURIComponent(field)}=true`];
16437
+ } else if (finalParams && typeof finalParams === "object") {
16438
+ finalParams = { ...finalParams, [field]: true };
16439
+ }
16440
+ }
16441
+ if (this.settings.includeCommitSequence === true) {
16442
+ if (Array.isArray(finalParams)) {
16443
+ finalParams = [...finalParams, `commitSequence=${sequence}`];
16444
+ } else if (finalParams && typeof finalParams === "object") {
16445
+ finalParams = { ...finalParams, commitSequence: sequence };
16446
+ }
16447
+ }
16448
+ const finalUrl = immediate && this.settings.terminateCommitParam ? appendQueryParam(url, this.settings.terminateCommitParam, "true") : url;
16449
+ const metadata = {
16450
+ isTerminateCommit: immediate,
16451
+ trigger: resolvedTrigger,
16452
+ sequence
16453
+ };
16454
+ const context = {
16455
+ url: finalUrl,
16456
+ trigger: resolvedTrigger,
16457
+ isTerminateCommit: immediate,
16458
+ sequence
16459
+ };
16460
+ if (this.settings.enableOfflineSupport && this._offlineStorageService && !this._offlineStorageService.isDeviceOnline() && this._courseId) {
16242
16461
  this.apiLog(
16243
16462
  "processHttpRequest",
16244
- "Invalid commit data format for offline storage",
16245
- LogLevelEnum.ERROR
16463
+ "Device is offline, storing data locally",
16464
+ LogLevelEnum.INFO
16246
16465
  );
16247
- return {
16248
- result: global_constants.SCORM_FALSE,
16249
- errorCode: this._error_codes.GENERAL ?? 101
16250
- };
16466
+ if (finalParams && typeof finalParams === "object" && "cmi" in finalParams) {
16467
+ return this._offlineStorageService.storeOffline(
16468
+ this._courseId,
16469
+ finalParams,
16470
+ { isTerminateCommit: immediate, sequence }
16471
+ );
16472
+ } else {
16473
+ this.apiLog(
16474
+ "processHttpRequest",
16475
+ "Invalid commit data format for offline storage",
16476
+ LogLevelEnum.ERROR
16477
+ );
16478
+ return {
16479
+ result: global_constants.SCORM_FALSE,
16480
+ errorCode: this._error_codes.GENERAL ?? 101
16481
+ };
16482
+ }
16483
+ }
16484
+ const apiLog = (functionName, message, level, element) => this.apiLog(functionName, message, level, element);
16485
+ const processListeners = (functionName, CMIElement, value) => {
16486
+ if (functionName === "CommitSuccess" || functionName === "CommitError") {
16487
+ if (functionName === "CommitError" && typeof value === "number") {
16488
+ context.errorCode = value;
16489
+ }
16490
+ settle();
16491
+ this.processListeners(functionName, CMIElement, value, context);
16492
+ } else {
16493
+ this.processListeners(functionName, CMIElement, value);
16494
+ }
16495
+ };
16496
+ const result = this._httpService.processHttpRequest(
16497
+ finalUrl,
16498
+ finalParams,
16499
+ immediate,
16500
+ apiLog,
16501
+ processListeners,
16502
+ metadata,
16503
+ settle
16504
+ );
16505
+ completionDeferred = this._httpService.reportsRequestCompletion === true;
16506
+ return result;
16507
+ } finally {
16508
+ if (!completionDeferred) {
16509
+ settle();
16251
16510
  }
16252
16511
  }
16253
- return this._httpService.processHttpRequest(
16254
- url,
16255
- params,
16256
- immediate,
16257
- (functionName, message, level, element) => this.apiLog(functionName, message, level, element),
16258
- (functionName, CMIElement, value) => this.processListeners(functionName, CMIElement, value)
16259
- );
16260
16512
  }
16261
16513
  /**
16262
16514
  * Schedules a commit operation to occur after a specified delay.
@@ -16559,6 +16811,49 @@ class CMILearnerPreference extends BaseCMI {
16559
16811
  }
16560
16812
  }
16561
16813
 
16814
+ function stripBrackets(delim) {
16815
+ return delim.replace(/[[\]]/g, "");
16816
+ }
16817
+ function escapeRegex(s) {
16818
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16819
+ }
16820
+ function splitDelimited(value, bracketed) {
16821
+ if (!bracketed) {
16822
+ return [value];
16823
+ }
16824
+ if (value.includes(bracketed)) {
16825
+ return value.split(bracketed);
16826
+ }
16827
+ const bare = stripBrackets(bracketed);
16828
+ if (!bare) {
16829
+ return [value];
16830
+ }
16831
+ const splitRe = new RegExp(`(?<!\\\\)${escapeRegex(bare)}`, "g");
16832
+ const unescapeRe = new RegExp(`\\\\${escapeRegex(bare)}`, "g");
16833
+ return value.split(splitRe).map((part) => part.replace(unescapeRe, bare));
16834
+ }
16835
+ function splitFirstDelimited(value, bracketed) {
16836
+ if (!bracketed) {
16837
+ return [value];
16838
+ }
16839
+ if (value.includes(bracketed)) {
16840
+ const idx = value.indexOf(bracketed);
16841
+ return [value.slice(0, idx), value.slice(idx + bracketed.length)];
16842
+ }
16843
+ const bare = stripBrackets(bracketed);
16844
+ if (!bare) {
16845
+ return [value];
16846
+ }
16847
+ const splitRe = new RegExp(`(?<!\\\\)${escapeRegex(bare)}`);
16848
+ const unescapeRe = new RegExp(`\\\\${escapeRegex(bare)}`, "g");
16849
+ const parts = value.split(splitRe);
16850
+ const first = (parts[0] ?? "").replace(unescapeRe, bare);
16851
+ if (parts.length === 1) {
16852
+ return [first];
16853
+ }
16854
+ return [first, parts.slice(1).join(bare).replace(unescapeRe, bare)];
16855
+ }
16856
+
16562
16857
  class CMIInteractions extends CMIArray {
16563
16858
  /**
16564
16859
  * Constructor for `cmi.interactions` Array
@@ -16768,8 +17063,7 @@ class CMIInteractionsObject extends BaseCMI {
16768
17063
  const response_type = LearnerResponses[this.type];
16769
17064
  if (response_type) {
16770
17065
  if (response_type?.delimiter) {
16771
- const delimiter = response_type.delimiter === "[,]" ? "," : response_type.delimiter;
16772
- nodes = learner_response.split(delimiter);
17066
+ nodes = splitDelimited(learner_response, response_type.delimiter);
16773
17067
  } else {
16774
17068
  nodes[0] = learner_response;
16775
17069
  }
@@ -16777,10 +17071,10 @@ class CMIInteractionsObject extends BaseCMI {
16777
17071
  const formatRegex = new RegExp(response_type.format);
16778
17072
  for (let i = 0; i < nodes.length; i++) {
16779
17073
  if (response_type?.delimiter2) {
16780
- const delimiter2 = response_type.delimiter2 === "[.]" ? "." : response_type.delimiter2;
16781
- const values = nodes[i]?.split(delimiter2);
17074
+ const node = nodes[i] ?? "";
17075
+ const values = this.type === "performance" ? splitFirstDelimited(node, response_type.delimiter2) : splitDelimited(node, response_type.delimiter2);
16782
17076
  if (values?.length === 2) {
16783
- if (this.type === "performance" && (values[0] === "" || values[1] === "")) {
17077
+ if (this.type === "performance" && values[0] === "" && values[1] === "") {
16784
17078
  throw new Scorm2004ValidationError(
16785
17079
  this._cmi_element + ".learner_response",
16786
17080
  scorm2004_errors.TYPE_MISMATCH
@@ -16999,30 +17293,13 @@ class CMIInteractionsObjectivesObject extends BaseCMI {
16999
17293
  return result;
17000
17294
  }
17001
17295
  }
17002
- function stripBrackets(delim) {
17003
- return delim.replace(/[[\]]/g, "");
17004
- }
17005
- function escapeRegex(s) {
17006
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17007
- }
17008
- function splitUnescaped(text, delim) {
17009
- const reDelim = escapeRegex(delim);
17010
- const splitRe = new RegExp(`(?<!\\\\)${reDelim}`, "g");
17011
- const unescapeRe = new RegExp(`\\\\${reDelim}`, "g");
17012
- return text.split(splitRe).map((part) => part.replace(unescapeRe, delim));
17013
- }
17014
- function splitFirstUnescaped(text, delim) {
17015
- const reDelim = escapeRegex(delim);
17016
- const splitRe = new RegExp(`(?<!\\\\)${reDelim}`);
17017
- const unescapeRe = new RegExp(`\\\\${reDelim}`, "g");
17018
- const parts = text.split(splitRe);
17019
- const firstPart = parts[0] ?? "";
17020
- if (parts.length === 1) {
17021
- return [firstPart.replace(unescapeRe, delim)];
17296
+ const RESPONSE_PREFIX_RE = /^\{(?:lang|case_matters|order_matters)=[^}]+\}/;
17297
+ function stripResponsePrefixes(node) {
17298
+ let result = node;
17299
+ while (RESPONSE_PREFIX_RE.test(result)) {
17300
+ result = result.replace(RESPONSE_PREFIX_RE, "");
17022
17301
  }
17023
- const part1 = firstPart.replace(unescapeRe, delim);
17024
- const part2 = parts.slice(1).join(delim).replace(unescapeRe, delim);
17025
- return [part1, part2];
17302
+ return result;
17026
17303
  }
17027
17304
  function validatePattern(type, pattern, responseDef) {
17028
17305
  if (pattern.trim() !== pattern) {
@@ -17031,8 +17308,7 @@ function validatePattern(type, pattern, responseDef) {
17031
17308
  scorm2004_errors.TYPE_MISMATCH
17032
17309
  );
17033
17310
  }
17034
- const subDelim1 = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : null;
17035
- const rawNodes = subDelim1 ? splitUnescaped(pattern, subDelim1) : [pattern];
17311
+ const rawNodes = responseDef.delimiter ? splitDelimited(pattern, responseDef.delimiter) : [pattern];
17036
17312
  for (const raw of rawNodes) {
17037
17313
  if (raw.trim() !== raw) {
17038
17314
  throw new Scorm2004ValidationError(
@@ -17044,20 +17320,14 @@ function validatePattern(type, pattern, responseDef) {
17044
17320
  if (type === "fill-in" && pattern === "") {
17045
17321
  return;
17046
17322
  }
17047
- const delim1 = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : null;
17048
- let nodes;
17049
- if (delim1) {
17050
- nodes = splitUnescaped(pattern, delim1);
17051
- } else {
17052
- nodes = [pattern];
17053
- }
17323
+ const nodes = responseDef.delimiter ? splitDelimited(pattern, responseDef.delimiter) : [pattern];
17054
17324
  if (!responseDef.delimiter && pattern.includes(",")) {
17055
17325
  throw new Scorm2004ValidationError(
17056
17326
  "cmi.interactions.n.correct_responses.n.pattern",
17057
17327
  scorm2004_errors.TYPE_MISMATCH
17058
17328
  );
17059
17329
  }
17060
- if (responseDef.unique || responseDef.duplicate === false) {
17330
+ if (type !== "numeric" && (responseDef.unique || responseDef.duplicate === false)) {
17061
17331
  const seen = new Set(nodes);
17062
17332
  if (seen.size !== nodes.length) {
17063
17333
  throw new Scorm2004ValidationError(
@@ -17089,8 +17359,7 @@ function validatePattern(type, pattern, responseDef) {
17089
17359
  scorm2004_errors.TYPE_MISMATCH
17090
17360
  );
17091
17361
  }
17092
- const delim = stripBrackets(delimBracketed);
17093
- const parts = value.split(new RegExp(`(?<!\\\\)${escapeRegex(delim)}`, "g")).map((n) => n.replace(new RegExp(`\\\\${escapeRegex(delim)}`, "g"), delim));
17362
+ const parts = splitDelimited(value, delimBracketed);
17094
17363
  if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
17095
17364
  throw new Scorm2004ValidationError(
17096
17365
  "cmi.interactions.n.correct_responses.n.pattern",
@@ -17107,15 +17376,17 @@ function validatePattern(type, pattern, responseDef) {
17107
17376
  for (const node of nodes) {
17108
17377
  switch (type) {
17109
17378
  case "numeric": {
17110
- const numDelim = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : ":";
17111
- const nums = node.split(numDelim);
17112
- if (nums.length < 1 || nums.length > 2) {
17113
- throw new Scorm2004ValidationError(
17114
- "cmi.interactions.n.correct_responses.n.pattern",
17115
- scorm2004_errors.TYPE_MISMATCH
17116
- );
17379
+ if (node === "") {
17380
+ const bracketedRange = nodes.length >= 2 && !!responseDef.delimiter && pattern.includes(responseDef.delimiter);
17381
+ if (!bracketedRange) {
17382
+ throw new Scorm2004ValidationError(
17383
+ "cmi.interactions.n.correct_responses.n.pattern",
17384
+ scorm2004_errors.TYPE_MISMATCH
17385
+ );
17386
+ }
17387
+ break;
17117
17388
  }
17118
- nums.forEach(checkSingle);
17389
+ checkSingle(node);
17119
17390
  break;
17120
17391
  }
17121
17392
  case "performance": {
@@ -17126,8 +17397,8 @@ function validatePattern(type, pattern, responseDef) {
17126
17397
  scorm2004_errors.TYPE_MISMATCH
17127
17398
  );
17128
17399
  }
17129
- const delim = stripBrackets(delimBracketed);
17130
- const parts = splitFirstUnescaped(node, delim);
17400
+ const record = stripResponsePrefixes(node);
17401
+ const parts = splitFirstDelimited(record, delimBracketed);
17131
17402
  if (parts.length !== 2) {
17132
17403
  throw new Scorm2004ValidationError(
17133
17404
  "cmi.interactions.n.correct_responses.n.pattern",
@@ -17135,7 +17406,7 @@ function validatePattern(type, pattern, responseDef) {
17135
17406
  );
17136
17407
  }
17137
17408
  const [part1, part2] = parts;
17138
- if (part1 === "" || part2 === "" || part1 === part2) {
17409
+ if (part1 === "" && part2 === "") {
17139
17410
  throw new Scorm2004ValidationError(
17140
17411
  "cmi.interactions.n.correct_responses.n.pattern",
17141
17412
  scorm2004_errors.TYPE_MISMATCH
@@ -17221,6 +17492,11 @@ class CMIScore extends BaseCMI {
17221
17492
  __invalid_range_code;
17222
17493
  __decimal_regex;
17223
17494
  __error_class;
17495
+ /**
17496
+ * When true, an empty string is a valid value (clears the element). SCORM 1.2
17497
+ * score elements may be blank per the ADL 1.2 CTS; SCORM 2004 leaves this off.
17498
+ */
17499
+ __allow_empty_string;
17224
17500
  _raw = "";
17225
17501
  _min = "";
17226
17502
  _max;
@@ -17255,6 +17531,7 @@ class CMIScore extends BaseCMI {
17255
17531
  this.__invalid_range_code = params.invalidRangeCode || scorm12_errors.VALUE_OUT_OF_RANGE;
17256
17532
  this.__decimal_regex = params.decimalRegex || scorm12_regex.CMIDecimal;
17257
17533
  this.__error_class = params.errorClass;
17534
+ this.__allow_empty_string = params.allowEmptyString ?? false;
17258
17535
  }
17259
17536
  /**
17260
17537
  * Called when the API has been reset
@@ -17301,7 +17578,8 @@ class CMIScore extends BaseCMI {
17301
17578
  this.__score_range,
17302
17579
  this.__invalid_type_code,
17303
17580
  this.__invalid_range_code,
17304
- this.__error_class
17581
+ this.__error_class,
17582
+ this.__allow_empty_string
17305
17583
  )) {
17306
17584
  this._raw = raw;
17307
17585
  }
@@ -17325,7 +17603,8 @@ class CMIScore extends BaseCMI {
17325
17603
  this.__score_range,
17326
17604
  this.__invalid_type_code,
17327
17605
  this.__invalid_range_code,
17328
- this.__error_class
17606
+ this.__error_class,
17607
+ this.__allow_empty_string
17329
17608
  )) {
17330
17609
  this._min = min;
17331
17610
  }
@@ -17349,7 +17628,8 @@ class CMIScore extends BaseCMI {
17349
17628
  this.__score_range,
17350
17629
  this.__invalid_type_code,
17351
17630
  this.__invalid_range_code,
17352
- this.__error_class
17631
+ this.__error_class,
17632
+ this.__allow_empty_string
17353
17633
  )) {
17354
17634
  this._max = max;
17355
17635
  }
@@ -20199,7 +20479,7 @@ class Scorm2004ResponseValidator {
20199
20479
  checkValidResponseType(CMIElement, response_type, value, interaction_type) {
20200
20480
  let nodes = [];
20201
20481
  if (response_type?.delimiter) {
20202
- nodes = String(value).split(response_type.delimiter);
20482
+ nodes = splitDelimited(String(value), response_type.delimiter);
20203
20483
  } else {
20204
20484
  nodes[0] = value;
20205
20485
  }
@@ -20321,22 +20601,30 @@ class Scorm2004ResponseValidator {
20321
20601
  nodes[i] = this.removeCorrectResponsePrefixes(CMIElement, nodes[i]);
20322
20602
  }
20323
20603
  if (response?.delimiter2) {
20324
- const values = nodes[i].split(response.delimiter2);
20604
+ const values = interaction_type === "performance" ? splitFirstDelimited(nodes[i], response.delimiter2) : splitDelimited(nodes[i], response.delimiter2);
20325
20605
  if (values.length === 2) {
20326
- const matches = values[0].match(formatRegex);
20327
- if (!matches) {
20606
+ if (interaction_type === "performance" && values[0] === "" && values[1] === "") {
20328
20607
  this.context.throwSCORMError(
20329
20608
  CMIElement,
20330
20609
  scorm2004_errors.TYPE_MISMATCH,
20331
20610
  `${interaction_type}: ${value}`
20332
20611
  );
20333
20612
  } else {
20334
- if (!response.format2 || !values[1].match(new RegExp(response.format2))) {
20613
+ const matches = values[0]?.match(formatRegex);
20614
+ if (!matches) {
20335
20615
  this.context.throwSCORMError(
20336
20616
  CMIElement,
20337
20617
  scorm2004_errors.TYPE_MISMATCH,
20338
20618
  `${interaction_type}: ${value}`
20339
20619
  );
20620
+ } else {
20621
+ if (!response.format2 || !values[1]?.match(new RegExp(response.format2))) {
20622
+ this.context.throwSCORMError(
20623
+ CMIElement,
20624
+ scorm2004_errors.TYPE_MISMATCH,
20625
+ `${interaction_type}: ${value}`
20626
+ );
20627
+ }
20340
20628
  }
20341
20629
  }
20342
20630
  } else {
@@ -20347,6 +20635,9 @@ class Scorm2004ResponseValidator {
20347
20635
  );
20348
20636
  }
20349
20637
  } else {
20638
+ if (interaction_type === "numeric" && nodes.length > 1 && nodes[i] === "" && !!response.delimiter && String(value).includes(response.delimiter)) {
20639
+ continue;
20640
+ }
20350
20641
  const matches = nodes[i].match(formatRegex);
20351
20642
  if (!matches && value !== "" || !matches && interaction_type === "true-false") {
20352
20643
  this.context.throwSCORMError(
@@ -20356,7 +20647,7 @@ class Scorm2004ResponseValidator {
20356
20647
  );
20357
20648
  } else {
20358
20649
  if (interaction_type === "numeric" && nodes.length > 1) {
20359
- if (Number(nodes[0]) > Number(nodes[1])) {
20650
+ if (nodes[0] !== "" && nodes[1] !== "" && Number(nodes[0]) > Number(nodes[1])) {
20360
20651
  this.context.throwSCORMError(
20361
20652
  CMIElement,
20362
20653
  scorm2004_errors.TYPE_MISMATCH,
@@ -22132,6 +22423,35 @@ class Scorm2004DataSerializer {
22132
22423
  }
22133
22424
  }
22134
22425
 
22426
+ const NO_DEFAULT_2004_ELEMENTS = /* @__PURE__ */ new Set([
22427
+ "cmi.suspend_data",
22428
+ "cmi.location",
22429
+ "cmi.scaled_passing_score",
22430
+ "cmi.max_time_allowed",
22431
+ "cmi.completion_threshold",
22432
+ "cmi.progress_measure",
22433
+ "cmi.score.scaled",
22434
+ "cmi.score.raw",
22435
+ "cmi.score.min",
22436
+ "cmi.score.max",
22437
+ "cmi.objectives.N.score.scaled",
22438
+ "cmi.objectives.N.score.raw",
22439
+ "cmi.objectives.N.score.min",
22440
+ "cmi.objectives.N.score.max",
22441
+ "cmi.objectives.N.progress_measure",
22442
+ "cmi.objectives.N.description",
22443
+ "cmi.interactions.N.weighting",
22444
+ "cmi.interactions.N.type",
22445
+ "cmi.interactions.N.timestamp",
22446
+ "cmi.interactions.N.result",
22447
+ "cmi.interactions.N.latency",
22448
+ "cmi.interactions.N.learner_response",
22449
+ "cmi.interactions.N.description",
22450
+ "cmi.comments_from_learner.N.timestamp"
22451
+ ]);
22452
+ function normalizeCMIIndices(CMIElement) {
22453
+ return CMIElement.replace(/\.\d+(?=\.|$)/g, ".N");
22454
+ }
22135
22455
  class Scorm2004API extends BaseAPI {
22136
22456
  _version = "1.0";
22137
22457
  _sequencing;
@@ -22657,6 +22977,39 @@ class Scorm2004API extends BaseAPI {
22657
22977
  getCMIValue(CMIElement) {
22658
22978
  return this._commonGetCMIValue("GetValue", true, CMIElement);
22659
22979
  }
22980
+ /**
22981
+ * Raises 403 (VALUE_NOT_INITIALIZED) when an implemented, no-default element is
22982
+ * read before it has ever been set, per IEEE 1484.11.2 / SCORM 2004 RTE.
22983
+ *
22984
+ * Invoked by BaseAPI.getValue after an otherwise-successful resolution, so it
22985
+ * only sees values the data model already returned cleanly. The decision is
22986
+ * deliberately gated to this public boundary (never the getters) to keep
22987
+ * serialization/commit/rollup — which read the getters directly — unaffected.
22988
+ *
22989
+ * A 403 is raised only when all hold:
22990
+ * - the resolved value is "" — any non-empty value is by definition set,
22991
+ * including values written by internal sequencing/activity-tree paths that
22992
+ * bypass _commonSetCMIValue (those only ever write non-empty values);
22993
+ * - the element was never set — _setCMIElements records every successful
22994
+ * SetValue, loadFromJSON, and global-objective restore (all route through
22995
+ * _commonSetCMIValue), so an explicit SetValue(x, "") still reads back as
22996
+ * "" with code 0; and
22997
+ * - the element has no spec-defined default ({@link NO_DEFAULT_2004_ELEMENTS}).
22998
+ *
22999
+ * @param {string} CMIElement
23000
+ * @param {*} returnValue - the value getCMIValue resolved
23001
+ * @protected
23002
+ */
23003
+ checkUninitializedGet(CMIElement, returnValue) {
23004
+ if (returnValue !== "") return;
23005
+ if (this._setCMIElements.has(CMIElement)) return;
23006
+ if (!NO_DEFAULT_2004_ELEMENTS.has(normalizeCMIIndices(CMIElement))) return;
23007
+ this.throwSCORMError(
23008
+ CMIElement,
23009
+ this._error_codes.VALUE_NOT_INITIALIZED ?? 403,
23010
+ `The data model element passed to GetValue (${CMIElement}) has not been initialized.`
23011
+ );
23012
+ }
22660
23013
  /**
22661
23014
  * Returns the message that corresponds to errorNumber.
22662
23015
  * @param {(string|number)} errorNumber
@@ -22703,9 +23056,10 @@ class Scorm2004API extends BaseAPI {
22703
23056
  /**
22704
23057
  * Attempts to store the data to the LMS
22705
23058
  * @param {boolean} terminateCommit
23059
+ * @param {CommitTrigger} [trigger] - What initiated the commit
22706
23060
  * @return {ResultObject}
22707
23061
  */
22708
- storeData(terminateCommit) {
23062
+ storeData(terminateCommit, trigger) {
22709
23063
  if (terminateCommit) {
22710
23064
  if (this.cmi.mode === "normal") {
22711
23065
  if (this.cmi.credit === "credit") {
@@ -22753,7 +23107,8 @@ class Scorm2004API extends BaseAPI {
22753
23107
  const result = this.processHttpRequest(
22754
23108
  this.settings.lmsCommitUrl,
22755
23109
  commitObject,
22756
- terminateCommit
23110
+ terminateCommit,
23111
+ trigger
22757
23112
  );
22758
23113
  if (navRequest && result.navRequest !== void 0 && result.navRequest !== "" && typeof result.navRequest === "string") {
22759
23114
  const parsed = parseNavigationRequest(result.navRequest);