scorm-again 3.0.4 → 3.0.5

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.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
+ # scorm-again
2
+
3
+ A modern, fully-tested JavaScript runtime for SCORM 1.2 and SCORM 2004.
4
+
5
+ ---
6
+ <details>
7
+ <summary><b>Audits & consulting →</b></summary>
8
+
9
+ I do one-week, fixed-price code audits for B2B SaaS companies on AWS:
10
+
11
+ - Multi-Tenant SaaS on AWS: architecture, cost, and SOC 2 alignment
12
+ - AI-on-AWS for Regulated SaaS: Bedrock, RAG, and guardrails under SOC 2, HIPAA, or 21 CFR Part 11
13
+
14
+ Fixed price, written report, walkthrough call. Details at [audits.putney.io](https://audits.putney.io).
15
+
16
+ </details>
17
+
18
+ ---
19
+
1
20
  [![Github Actions](https://img.shields.io/github/actions/workflow/status/jcputney/scorm-again/main.yml?style=for-the-badge "Build Status")](https://github.com/jcputney/scorm-again/actions)
2
21
  [![Codecov](https://img.shields.io/codecov/c/github/jcputney/scorm-again?style=for-the-badge "Code Coverage")](https://codecov.io/gh/jcputney/scorm-again)
3
22
  ![jsDelivr hits (npm)](https://img.shields.io/jsdelivr/npm/hy/scorm-again?style=for-the-badge&label=jsDelivr%20Downloads)
@@ -7,10 +26,6 @@
7
26
  ![GitHub License](https://img.shields.io/github/license/jcputney/scorm-again?style=for-the-badge)
8
27
  [![donate](https://img.shields.io/badge/paypal-donate-success?style=for-the-badge)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=NF5MPZJAV26LE)
9
28
 
10
- # scorm-again
11
-
12
- A modern, fully-tested JavaScript runtime for SCORM 1.2 and SCORM 2004.
13
-
14
29
  > **Upgrading from v2.x?** See the [Migration Guide](MIGRATION.md) for breaking changes and migration steps.
15
30
 
16
31
  ## Overview
@@ -1160,6 +1160,11 @@ const scorm2004_regex = {
1160
1160
  progress_range: "0#1"
1161
1161
  };
1162
1162
 
1163
+ const PERFORMANCE_STEP_NAME = "^$|" + scorm2004_regex.CMIShortIdentifier;
1164
+ const PERFORMANCE_CHARACTERSTRING = "(?![\\s\\S]*(?:\\[,\\]|\\[\\.\\]|\\[:\\]))[\\s\\S]{1,250}";
1165
+ const PERFORMANCE_NUMERIC_RANGE = "(?:-?\\d+(?:\\.\\d+)?)?\\[:\\](?:-?\\d+(?:\\.\\d+)?)?";
1166
+ const CR_PERFORMANCE_STEP_ANSWER = "^(?:|" + PERFORMANCE_NUMERIC_RANGE + "|" + PERFORMANCE_CHARACTERSTRING + ")$";
1167
+ const LR_PERFORMANCE_STEP_ANSWER = "^(?:|" + PERFORMANCE_CHARACTERSTRING + ")$";
1163
1168
  const LearnerResponses = {
1164
1169
  "true-false": {
1165
1170
  format: "^true$|^false$",
@@ -1194,8 +1199,8 @@ const LearnerResponses = {
1194
1199
  unique: false
1195
1200
  },
1196
1201
  performance: {
1197
- format: "^$|" + scorm2004_regex.CMIShortIdentifier,
1198
- format2: scorm2004_regex.CMIDecimal + "|^$|" + scorm2004_regex.CMIShortIdentifier,
1202
+ format: PERFORMANCE_STEP_NAME,
1203
+ format2: LR_PERFORMANCE_STEP_ANSWER,
1199
1204
  max: 250,
1200
1205
  delimiter: "[,]",
1201
1206
  delimiter2: "[.]",
@@ -1271,10 +1276,10 @@ const CorrectResponses = {
1271
1276
  delimiter2: "[.]",
1272
1277
  unique: false,
1273
1278
  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+)?)?)$`
1279
+ // step_name: optional short_identifier_type
1280
+ format: PERFORMANCE_STEP_NAME,
1281
+ // step_answer: optional characterstring (spaces allowed) or numeric range
1282
+ format2: CR_PERFORMANCE_STEP_ANSWER
1278
1283
  },
1279
1284
  sequencing: {
1280
1285
  max: 36,
@@ -5583,7 +5588,19 @@ class CMIValueAccessService {
5583
5588
  if (!scorm2004) {
5584
5589
  if (isFinalAttribute) {
5585
5590
  if (typeof attribute === "undefined" || !this.context.checkObjectHasProperty(refObject, attribute)) {
5586
- this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
5591
+ if (attribute === "_children") {
5592
+ this.context.throwSCORMError(
5593
+ CMIElement,
5594
+ getErrorCode(this.context.errorCodes, "CHILDREN_ERROR")
5595
+ );
5596
+ } else if (attribute === "_count") {
5597
+ this.context.throwSCORMError(
5598
+ CMIElement,
5599
+ getErrorCode(this.context.errorCodes, "COUNT_ERROR")
5600
+ );
5601
+ } else {
5602
+ this.context.throwSCORMError(CMIElement, invalidErrorCode, invalidErrorMessage);
5603
+ }
5587
5604
  return { error: true };
5588
5605
  }
5589
5606
  }
@@ -15987,7 +16004,8 @@ class BaseAPI {
15987
16004
  if (errorCode === 143) returnValue = global_constants.SCORM_FALSE;
15988
16005
  } else {
15989
16006
  const result = this.storeData(false);
15990
- if ((result.errorCode ?? 0) > 0) {
16007
+ const errorCode = result.errorCode ?? 0;
16008
+ if (errorCode > 0) {
15991
16009
  if (result.errorMessage) {
15992
16010
  this.apiLog(
15993
16011
  "commit",
@@ -16002,12 +16020,12 @@ class BaseAPI {
16002
16020
  LogLevelEnum.DEBUG
16003
16021
  );
16004
16022
  }
16005
- this.throwSCORMError("api", result.errorCode);
16023
+ this.throwSCORMError("api", errorCode);
16006
16024
  }
16007
16025
  const resultValue = result?.result ?? global_constants.SCORM_FALSE;
16008
16026
  returnValue = typeof resultValue === "boolean" ? String(resultValue) : resultValue;
16009
16027
  this.apiLog(callbackName, " Result: " + returnValue, LogLevelEnum.DEBUG, "HttpRequest");
16010
- if (checkTerminated) this.lastErrorCode = "0";
16028
+ if (checkTerminated && errorCode === 0) this.lastErrorCode = "0";
16011
16029
  this.processListeners(callbackName);
16012
16030
  if (this.settings.enableOfflineSupport && this._offlineStorageService && this._offlineStorageService.isDeviceOnline() && this._courseId) {
16013
16031
  this._offlineStorageService.hasPendingOfflineData(this._courseId).then((hasPendingData) => {
@@ -18930,6 +18948,49 @@ class CMILearnerPreference extends BaseCMI {
18930
18948
  }
18931
18949
  }
18932
18950
 
18951
+ function stripBrackets(delim) {
18952
+ return delim.replace(/[[\]]/g, "");
18953
+ }
18954
+ function escapeRegex(s) {
18955
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18956
+ }
18957
+ function splitDelimited(value, bracketed) {
18958
+ if (!bracketed) {
18959
+ return [value];
18960
+ }
18961
+ if (value.includes(bracketed)) {
18962
+ return value.split(bracketed);
18963
+ }
18964
+ const bare = stripBrackets(bracketed);
18965
+ if (!bare) {
18966
+ return [value];
18967
+ }
18968
+ const splitRe = new RegExp(`(?<!\\\\)${escapeRegex(bare)}`, "g");
18969
+ const unescapeRe = new RegExp(`\\\\${escapeRegex(bare)}`, "g");
18970
+ return value.split(splitRe).map((part) => part.replace(unescapeRe, bare));
18971
+ }
18972
+ function splitFirstDelimited(value, bracketed) {
18973
+ if (!bracketed) {
18974
+ return [value];
18975
+ }
18976
+ if (value.includes(bracketed)) {
18977
+ const idx = value.indexOf(bracketed);
18978
+ return [value.slice(0, idx), value.slice(idx + bracketed.length)];
18979
+ }
18980
+ const bare = stripBrackets(bracketed);
18981
+ if (!bare) {
18982
+ return [value];
18983
+ }
18984
+ const splitRe = new RegExp(`(?<!\\\\)${escapeRegex(bare)}`);
18985
+ const unescapeRe = new RegExp(`\\\\${escapeRegex(bare)}`, "g");
18986
+ const parts = value.split(splitRe);
18987
+ const first = (parts[0] ?? "").replace(unescapeRe, bare);
18988
+ if (parts.length === 1) {
18989
+ return [first];
18990
+ }
18991
+ return [first, parts.slice(1).join(bare).replace(unescapeRe, bare)];
18992
+ }
18993
+
18933
18994
  class CMIInteractions extends CMIArray {
18934
18995
  /**
18935
18996
  * Constructor for `cmi.interactions` Array
@@ -19139,8 +19200,7 @@ class CMIInteractionsObject extends BaseCMI {
19139
19200
  const response_type = LearnerResponses[this.type];
19140
19201
  if (response_type) {
19141
19202
  if (response_type?.delimiter) {
19142
- const delimiter = response_type.delimiter === "[,]" ? "," : response_type.delimiter;
19143
- nodes = learner_response.split(delimiter);
19203
+ nodes = splitDelimited(learner_response, response_type.delimiter);
19144
19204
  } else {
19145
19205
  nodes[0] = learner_response;
19146
19206
  }
@@ -19148,10 +19208,10 @@ class CMIInteractionsObject extends BaseCMI {
19148
19208
  const formatRegex = new RegExp(response_type.format);
19149
19209
  for (let i = 0; i < nodes.length; i++) {
19150
19210
  if (response_type?.delimiter2) {
19151
- const delimiter2 = response_type.delimiter2 === "[.]" ? "." : response_type.delimiter2;
19152
- const values = nodes[i]?.split(delimiter2);
19211
+ const node = nodes[i] ?? "";
19212
+ const values = this.type === "performance" ? splitFirstDelimited(node, response_type.delimiter2) : splitDelimited(node, response_type.delimiter2);
19153
19213
  if (values?.length === 2) {
19154
- if (this.type === "performance" && (values[0] === "" || values[1] === "")) {
19214
+ if (this.type === "performance" && values[0] === "" && values[1] === "") {
19155
19215
  throw new Scorm2004ValidationError(
19156
19216
  this._cmi_element + ".learner_response",
19157
19217
  scorm2004_errors.TYPE_MISMATCH
@@ -19370,30 +19430,13 @@ class CMIInteractionsObjectivesObject extends BaseCMI {
19370
19430
  return result;
19371
19431
  }
19372
19432
  }
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)];
19433
+ const RESPONSE_PREFIX_RE = /^\{(?:lang|case_matters|order_matters)=[^}]+\}/;
19434
+ function stripResponsePrefixes(node) {
19435
+ let result = node;
19436
+ while (RESPONSE_PREFIX_RE.test(result)) {
19437
+ result = result.replace(RESPONSE_PREFIX_RE, "");
19393
19438
  }
19394
- const part1 = firstPart.replace(unescapeRe, delim);
19395
- const part2 = parts.slice(1).join(delim).replace(unescapeRe, delim);
19396
- return [part1, part2];
19439
+ return result;
19397
19440
  }
19398
19441
  function validatePattern(type, pattern, responseDef) {
19399
19442
  if (pattern.trim() !== pattern) {
@@ -19402,8 +19445,7 @@ function validatePattern(type, pattern, responseDef) {
19402
19445
  scorm2004_errors.TYPE_MISMATCH
19403
19446
  );
19404
19447
  }
19405
- const subDelim1 = responseDef.delimiter ? stripBrackets(responseDef.delimiter) : null;
19406
- const rawNodes = subDelim1 ? splitUnescaped(pattern, subDelim1) : [pattern];
19448
+ const rawNodes = responseDef.delimiter ? splitDelimited(pattern, responseDef.delimiter) : [pattern];
19407
19449
  for (const raw of rawNodes) {
19408
19450
  if (raw.trim() !== raw) {
19409
19451
  throw new Scorm2004ValidationError(
@@ -19415,20 +19457,14 @@ function validatePattern(type, pattern, responseDef) {
19415
19457
  if (type === "fill-in" && pattern === "") {
19416
19458
  return;
19417
19459
  }
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
- }
19460
+ const nodes = responseDef.delimiter ? splitDelimited(pattern, responseDef.delimiter) : [pattern];
19425
19461
  if (!responseDef.delimiter && pattern.includes(",")) {
19426
19462
  throw new Scorm2004ValidationError(
19427
19463
  "cmi.interactions.n.correct_responses.n.pattern",
19428
19464
  scorm2004_errors.TYPE_MISMATCH
19429
19465
  );
19430
19466
  }
19431
- if (responseDef.unique || responseDef.duplicate === false) {
19467
+ if (type !== "numeric" && (responseDef.unique || responseDef.duplicate === false)) {
19432
19468
  const seen = new Set(nodes);
19433
19469
  if (seen.size !== nodes.length) {
19434
19470
  throw new Scorm2004ValidationError(
@@ -19460,8 +19496,7 @@ function validatePattern(type, pattern, responseDef) {
19460
19496
  scorm2004_errors.TYPE_MISMATCH
19461
19497
  );
19462
19498
  }
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));
19499
+ const parts = splitDelimited(value, delimBracketed);
19465
19500
  if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
19466
19501
  throw new Scorm2004ValidationError(
19467
19502
  "cmi.interactions.n.correct_responses.n.pattern",
@@ -19478,15 +19513,17 @@ function validatePattern(type, pattern, responseDef) {
19478
19513
  for (const node of nodes) {
19479
19514
  switch (type) {
19480
19515
  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
- );
19516
+ if (node === "") {
19517
+ const bracketedRange = nodes.length >= 2 && !!responseDef.delimiter && pattern.includes(responseDef.delimiter);
19518
+ if (!bracketedRange) {
19519
+ throw new Scorm2004ValidationError(
19520
+ "cmi.interactions.n.correct_responses.n.pattern",
19521
+ scorm2004_errors.TYPE_MISMATCH
19522
+ );
19523
+ }
19524
+ break;
19488
19525
  }
19489
- nums.forEach(checkSingle);
19526
+ checkSingle(node);
19490
19527
  break;
19491
19528
  }
19492
19529
  case "performance": {
@@ -19497,8 +19534,8 @@ function validatePattern(type, pattern, responseDef) {
19497
19534
  scorm2004_errors.TYPE_MISMATCH
19498
19535
  );
19499
19536
  }
19500
- const delim = stripBrackets(delimBracketed);
19501
- const parts = splitFirstUnescaped(node, delim);
19537
+ const record = stripResponsePrefixes(node);
19538
+ const parts = splitFirstDelimited(record, delimBracketed);
19502
19539
  if (parts.length !== 2) {
19503
19540
  throw new Scorm2004ValidationError(
19504
19541
  "cmi.interactions.n.correct_responses.n.pattern",
@@ -19506,7 +19543,7 @@ function validatePattern(type, pattern, responseDef) {
19506
19543
  );
19507
19544
  }
19508
19545
  const [part1, part2] = parts;
19509
- if (part1 === "" || part2 === "" || part1 === part2) {
19546
+ if (part1 === "" && part2 === "") {
19510
19547
  throw new Scorm2004ValidationError(
19511
19548
  "cmi.interactions.n.correct_responses.n.pattern",
19512
19549
  scorm2004_errors.TYPE_MISMATCH
@@ -22385,7 +22422,7 @@ class Scorm2004ResponseValidator {
22385
22422
  checkValidResponseType(CMIElement, response_type, value, interaction_type) {
22386
22423
  let nodes = [];
22387
22424
  if (response_type?.delimiter) {
22388
- nodes = String(value).split(response_type.delimiter);
22425
+ nodes = splitDelimited(String(value), response_type.delimiter);
22389
22426
  } else {
22390
22427
  nodes[0] = value;
22391
22428
  }
@@ -22507,22 +22544,30 @@ class Scorm2004ResponseValidator {
22507
22544
  nodes[i] = this.removeCorrectResponsePrefixes(CMIElement, nodes[i]);
22508
22545
  }
22509
22546
  if (response?.delimiter2) {
22510
- const values = nodes[i].split(response.delimiter2);
22547
+ const values = interaction_type === "performance" ? splitFirstDelimited(nodes[i], response.delimiter2) : splitDelimited(nodes[i], response.delimiter2);
22511
22548
  if (values.length === 2) {
22512
- const matches = values[0].match(formatRegex);
22513
- if (!matches) {
22549
+ if (interaction_type === "performance" && values[0] === "" && values[1] === "") {
22514
22550
  this.context.throwSCORMError(
22515
22551
  CMIElement,
22516
22552
  scorm2004_errors.TYPE_MISMATCH,
22517
22553
  `${interaction_type}: ${value}`
22518
22554
  );
22519
22555
  } else {
22520
- if (!response.format2 || !values[1].match(new RegExp(response.format2))) {
22556
+ const matches = values[0]?.match(formatRegex);
22557
+ if (!matches) {
22521
22558
  this.context.throwSCORMError(
22522
22559
  CMIElement,
22523
22560
  scorm2004_errors.TYPE_MISMATCH,
22524
22561
  `${interaction_type}: ${value}`
22525
22562
  );
22563
+ } else {
22564
+ if (!response.format2 || !values[1]?.match(new RegExp(response.format2))) {
22565
+ this.context.throwSCORMError(
22566
+ CMIElement,
22567
+ scorm2004_errors.TYPE_MISMATCH,
22568
+ `${interaction_type}: ${value}`
22569
+ );
22570
+ }
22526
22571
  }
22527
22572
  }
22528
22573
  } else {
@@ -22533,6 +22578,9 @@ class Scorm2004ResponseValidator {
22533
22578
  );
22534
22579
  }
22535
22580
  } else {
22581
+ if (interaction_type === "numeric" && nodes.length > 1 && nodes[i] === "" && !!response.delimiter && String(value).includes(response.delimiter)) {
22582
+ continue;
22583
+ }
22536
22584
  const matches = nodes[i].match(formatRegex);
22537
22585
  if (!matches && value !== "" || !matches && interaction_type === "true-false") {
22538
22586
  this.context.throwSCORMError(
@@ -22542,7 +22590,7 @@ class Scorm2004ResponseValidator {
22542
22590
  );
22543
22591
  } else {
22544
22592
  if (interaction_type === "numeric" && nodes.length > 1) {
22545
- if (Number(nodes[0]) > Number(nodes[1])) {
22593
+ if (nodes[0] !== "" && nodes[1] !== "" && Number(nodes[0]) > Number(nodes[1])) {
22546
22594
  this.context.throwSCORMError(
22547
22595
  CMIElement,
22548
22596
  scorm2004_errors.TYPE_MISMATCH,