coursecode 0.1.56 → 0.1.58

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 (86) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +49 -5
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +28 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +6 -2
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/package.json +1 -1
  86. package/template/vite.config.js +50 -22
@@ -14,6 +14,20 @@ import { ScormDriverBase } from './scorm-driver-base.js';
14
14
  import { eventBus } from '../core/event-bus.js';
15
15
  import { logger } from '../utilities/logger.js';
16
16
  import LZString from 'lz-string';
17
+ import { serializeInteractionForScorm12 } from '../validation/scorm-validators.js';
18
+
19
+ const SCORM12_SUSPEND_PREFIX = 'CC12:';
20
+
21
+ function parseFiniteNumber(value, fallback = null) {
22
+ if (value === null || value === undefined || String(value).trim() === '') return fallback;
23
+ const parsed = Number(value);
24
+ return Number.isFinite(parsed) ? parsed : fallback;
25
+ }
26
+
27
+ function parseNonNegativeInteger(value) {
28
+ const parsed = Number(value);
29
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
30
+ }
17
31
 
18
32
  // =============================================================================
19
33
  // Status Mapping
@@ -58,6 +72,12 @@ function mapStatusTo2004(lessonStatus) {
58
72
  }
59
73
  }
60
74
 
75
+ function mapObjectiveStatusTo12(completionStatus, successStatus) {
76
+ if (successStatus === 'passed') return 'passed';
77
+ if (successStatus === 'failed') return 'failed';
78
+ return mapStatusTo12(completionStatus, successStatus);
79
+ }
80
+
61
81
  // =============================================================================
62
82
  // Time Format Conversion
63
83
  // =============================================================================
@@ -82,6 +102,86 @@ function convertTimeFormat2004To12(iso8601) {
82
102
  return `${hStr}:${mStr}:${sStr}`;
83
103
  }
84
104
 
105
+ /**
106
+ * Converts a SCORM 2004 timestamp to the SCORM 1.2 interaction time format.
107
+ * SCORM 1.2 stores only the time-of-day portion in cmi.interactions.n.time.
108
+ */
109
+ function convertTimestamp2004To12(timestamp) {
110
+ if (!timestamp || typeof timestamp !== 'string') return '00:00:00';
111
+ const match = timestamp.match(/(?:T|^)(\d{2}):(\d{2}):(\d{2})/);
112
+ return match ? `${match[1]}:${match[2]}:${match[3]}` : '00:00:00';
113
+ }
114
+
115
+ /**
116
+ * Produces the compact suspend-data representation used by SCORM 1.2.
117
+ * Version 2 is lossless: the previous diet silently discarded objective,
118
+ * engagement, response, metadata, and extension-domain state on every save.
119
+ */
120
+ function createScorm12DietState(fullState, currentSlide = null) {
121
+ const state = { ...fullState };
122
+ if (fullState.navigation && currentSlide) {
123
+ state.navigation = { ...fullState.navigation, currentSlide };
124
+ }
125
+ return { v: 2, s: state };
126
+ }
127
+
128
+ function expandScorm12DietState(dietState) {
129
+ if (dietState?.v === 2 && dietState.s && typeof dietState.s === 'object') {
130
+ return dietState.s;
131
+ }
132
+
133
+ // Backward-compatible reader for packages published with the original
134
+ // lossy diet representation.
135
+ const expanded = {};
136
+
137
+ if (dietState.nav) {
138
+ expanded.navigation = {
139
+ currentSlide: dietState.nav.cur,
140
+ visitedSlides: dietState.nav.vis || []
141
+ };
142
+ }
143
+ if (dietState.acc) expanded.accessibility = dietState.acc;
144
+ if (dietState.flg) expanded.flags = dietState.flg;
145
+
146
+ if (dietState.eng) {
147
+ expanded.engagement = {};
148
+ for (const [slideId, slideState] of Object.entries(dietState.eng)) {
149
+ expanded.engagement[slideId] = {
150
+ complete: slideState.c === 1,
151
+ tracked: {}
152
+ };
153
+ }
154
+ }
155
+
156
+ if (dietState.int) expanded.interactionResponses = dietState.int;
157
+
158
+ for (const [key, value] of Object.entries(dietState)) {
159
+ if (key.startsWith('as_')) expanded[`assessment_${key.substring(3)}`] = value;
160
+ }
161
+
162
+ return expanded;
163
+ }
164
+
165
+ function encodeScorm12SuspendState(state) {
166
+ return SCORM12_SUSPEND_PREFIX + LZString.compressToEncodedURIComponent(JSON.stringify(state));
167
+ }
168
+
169
+ function decodeScorm12SuspendState(value) {
170
+ if (value.startsWith(SCORM12_SUSPEND_PREFIX)) {
171
+ const decoded = LZString.decompressFromEncodedURIComponent(
172
+ value.slice(SCORM12_SUSPEND_PREFIX.length)
173
+ );
174
+ if (!decoded) throw new Error('SCORM 1.2 suspend_data is corrupted or truncated');
175
+ return JSON.parse(decoded);
176
+ }
177
+
178
+ // Read legacy UTF-16 CourseCode state without writing it back until a
179
+ // successful, explicit commit migrates it to the ASCII-safe format.
180
+ const legacy = LZString.decompressFromUTF16(value);
181
+ if (!legacy) throw new Error('SCORM 1.2 suspend_data is corrupted or uses an unsupported encoding');
182
+ return JSON.parse(legacy);
183
+ }
184
+
85
185
  // =============================================================================
86
186
  // SCORM 1.2 Driver Class (using pipwerks)
87
187
  // =============================================================================
@@ -104,6 +204,11 @@ export class Scorm12Driver extends ScormDriverBase {
104
204
  learnerName: '',
105
205
  interactionsCount: 0
106
206
  };
207
+ this._objectiveIdToIndex = new Map();
208
+ this._objectivesCount = 0;
209
+ this._supportsObjectives = false;
210
+ this._supportsInteractions = false;
211
+ this._scoreChildren = new Set(['raw']);
107
212
  }
108
213
 
109
214
  // =========================================================================
@@ -116,10 +221,10 @@ export class Scorm12Driver extends ScormDriverBase {
116
221
 
117
222
  getCapabilities() {
118
223
  return {
119
- supportsObjectives: true,
120
- supportsInteractions: true,
224
+ supportsObjectives: this._supportsObjectives,
225
+ supportsInteractions: this._supportsInteractions,
121
226
  supportsComments: false, // SCORM 1.2 comments are read-only
122
- supportsEmergencySave: false,
227
+ supportsEmergencySave: true,
123
228
  maxSuspendDataBytes: 4096,
124
229
  asyncCommit: false
125
230
  };
@@ -187,15 +292,15 @@ export class Scorm12Driver extends ScormDriverBase {
187
292
  try {
188
293
  const rawStr = this._scorm.get('cmi.core.score.raw');
189
294
  if (!rawStr) return null;
190
- const raw = parseFloat(rawStr);
191
- if (isNaN(raw)) return null;
192
- const minStr = this._scorm.get('cmi.core.score.min');
193
- const maxStr = this._scorm.get('cmi.core.score.max');
295
+ const raw = parseFiniteNumber(rawStr);
296
+ if (raw === null) return null;
297
+ const minStr = this._scoreChildren.has('min') ? this._scorm.get('cmi.core.score.min') : '';
298
+ const maxStr = this._scoreChildren.has('max') ? this._scorm.get('cmi.core.score.max') : '';
194
299
  return {
195
300
  scaled: raw / 100,
196
301
  raw,
197
- min: minStr ? parseFloat(minStr) : 0,
198
- max: maxStr ? parseFloat(maxStr) : 100
302
+ min: parseFiniteNumber(minStr, 0),
303
+ max: parseFiniteNumber(maxStr, 100)
199
304
  };
200
305
  } catch (_e) {
201
306
  return null;
@@ -221,8 +326,8 @@ export class Scorm12Driver extends ScormDriverBase {
221
326
  reportScore({ raw, min, max }) {
222
327
  // SCORM 1.2 doesn't have scaled score — silently ignored
223
328
  if (raw !== undefined) this._rawSet('cmi.core.score.raw', String(raw));
224
- if (min !== undefined) this._rawSet('cmi.core.score.min', String(min));
225
- if (max !== undefined) this._rawSet('cmi.core.score.max', String(max));
329
+ if (min !== undefined && this._scoreChildren.has('min')) this._rawSet('cmi.core.score.min', String(min));
330
+ if (max !== undefined && this._scoreChildren.has('max')) this._rawSet('cmi.core.score.max', String(max));
226
331
  }
227
332
 
228
333
  reportCompletion(status) {
@@ -246,6 +351,7 @@ export class Scorm12Driver extends ScormDriverBase {
246
351
 
247
352
  reportObjective(objective) {
248
353
  if (!objective || !objective.id) return;
354
+ if (!this._supportsObjectives) return;
249
355
 
250
356
  // SCORM 1.2 objectives support: id, score.raw, score.min, score.max, status
251
357
  // But NOT success_status or completion_status separately
@@ -256,17 +362,13 @@ export class Scorm12Driver extends ScormDriverBase {
256
362
  this._rawSet(`cmi.objectives.${index}.score.raw`, String(objective.score));
257
363
  this._rawSet(`cmi.objectives.${index}.score.min`, '0');
258
364
  this._rawSet(`cmi.objectives.${index}.score.max`, '100');
259
-
260
- // Map success_status to 1.2 objective status
261
- if (objective.success_status === 'passed') {
262
- this._rawSet(`cmi.objectives.${index}.status`, 'passed');
263
- } else if (objective.success_status === 'failed') {
264
- this._rawSet(`cmi.objectives.${index}.status`, 'failed');
265
- }
266
365
  }
267
366
 
268
- if (objective.completion_status === 'completed' && !objective.success_status) {
269
- this._rawSet(`cmi.objectives.${index}.status`, 'completed');
367
+ if (objective.completion_status !== undefined || objective.success_status !== undefined) {
368
+ this._rawSet(
369
+ `cmi.objectives.${index}.status`,
370
+ mapObjectiveStatusTo12(objective.completion_status, objective.success_status)
371
+ );
270
372
  }
271
373
  }
272
374
 
@@ -275,31 +377,37 @@ export class Scorm12Driver extends ScormDriverBase {
275
377
  throw new Error('Scorm12Driver: interaction.id and interaction.type are required');
276
378
  }
277
379
 
380
+ if (!this._supportsInteractions) {
381
+ return { ...interaction, _index: null, nativeCmiSkipped: true };
382
+ }
383
+
384
+ const serializedInteraction = serializeInteractionForScorm12(interaction);
385
+
278
386
  const index = this._cache.interactionsCount;
279
387
 
280
388
  // SCORM 1.2 interaction fields
281
- this._rawSet(`cmi.interactions.${index}.id`, interaction.id);
282
- this._rawSet(`cmi.interactions.${index}.type`, interaction.type);
389
+ this._rawSet(`cmi.interactions.${index}.id`, serializedInteraction.id);
390
+ this._rawSet(`cmi.interactions.${index}.type`, serializedInteraction.type);
283
391
 
284
- if (interaction.learner_response !== undefined && interaction.learner_response !== null && interaction.learner_response !== '') {
285
- this._rawSet(`cmi.interactions.${index}.student_response`, interaction.learner_response);
392
+ if (serializedInteraction.learner_response !== '') {
393
+ this._rawSet(`cmi.interactions.${index}.student_response`, serializedInteraction.learner_response);
286
394
  }
287
- if (interaction.result) {
288
- this._rawSet(`cmi.interactions.${index}.result`, interaction.result);
395
+ if (serializedInteraction.result) {
396
+ this._rawSet(`cmi.interactions.${index}.result`, serializedInteraction.result);
289
397
  }
290
398
  if (interaction.timestamp) {
291
- this._rawSet(`cmi.interactions.${index}.time`, interaction.timestamp);
399
+ this._rawSet(`cmi.interactions.${index}.time`, convertTimestamp2004To12(interaction.timestamp));
292
400
  }
293
401
  if (interaction.weighting !== undefined && interaction.weighting !== null) {
294
402
  this._rawSet(`cmi.interactions.${index}.weighting`, String(interaction.weighting));
295
403
  }
296
404
  if (interaction.latency) {
297
- this._rawSet(`cmi.interactions.${index}.latency`, interaction.latency);
405
+ this._rawSet(`cmi.interactions.${index}.latency`, convertTimeFormat2004To12(interaction.latency));
298
406
  }
299
407
 
300
408
  // correct_responses
301
- if (interaction.correct_responses && Array.isArray(interaction.correct_responses)) {
302
- interaction.correct_responses.forEach((item, patternIndex) => {
409
+ if (serializedInteraction.correct_responses && Array.isArray(serializedInteraction.correct_responses)) {
410
+ serializedInteraction.correct_responses.forEach((item, patternIndex) => {
303
411
  const patternValue = (typeof item === 'object' && item !== null && 'pattern' in item)
304
412
  ? item.pattern
305
413
  : item;
@@ -308,8 +416,8 @@ export class Scorm12Driver extends ScormDriverBase {
308
416
  }
309
417
 
310
418
  // objectives
311
- if (interaction.objectives && Array.isArray(interaction.objectives)) {
312
- interaction.objectives.forEach((objectiveId, objIndex) => {
419
+ if (serializedInteraction.objectives && Array.isArray(serializedInteraction.objectives)) {
420
+ serializedInteraction.objectives.forEach((objectiveId, objIndex) => {
313
421
  this._rawSet(`cmi.interactions.${index}.objectives.${objIndex}.id`, objectiveId);
314
422
  });
315
423
  }
@@ -330,24 +438,18 @@ export class Scorm12Driver extends ScormDriverBase {
330
438
  // =========================================================================
331
439
 
332
440
  getSuspendData() {
333
- const data = this._scorm.get('cmi.suspend_data') || '';
441
+ const data = this._rawGet('cmi.suspend_data');
334
442
 
335
443
  if (!data) {
336
444
  return null;
337
445
  }
338
446
 
339
- const jsonString = LZString.decompressFromUTF16(data);
340
- if (!jsonString) {
341
- logger.warn('[Scorm12Driver] Failed to decompress suspend_data');
342
- return null;
343
- }
344
-
345
447
  try {
346
- const parsed = JSON.parse(jsonString);
448
+ const parsed = decodeScorm12SuspendState(data);
347
449
  return this._expandDietState(parsed);
348
450
  } catch (error) {
349
- logger.error('[Scorm12Driver] Failed to parse suspend_data:', error);
350
- throw new Error(`Failed to parse suspend data: ${error.message}`);
451
+ logger.error('[Scorm12Driver] Failed to decode suspend_data:', error);
452
+ throw new Error(`SCORM 1.2 resume state cannot be safely restored: ${error.message}`);
351
453
  }
352
454
  }
353
455
 
@@ -357,10 +459,9 @@ export class Scorm12Driver extends ScormDriverBase {
357
459
  }
358
460
 
359
461
  // STRICT DIET MODE: Always prune, never adaptive
360
- const dietData = this._createDietState(data);
462
+ const dietData = createScorm12DietState(data, this._cache.bookmark || null);
361
463
 
362
- const serialized = JSON.stringify(dietData);
363
- const compressed = LZString.compressToUTF16(serialized);
464
+ const compressed = encodeScorm12SuspendState(dietData);
364
465
 
365
466
  const compressedSizeKB = (compressed.length / 1024).toFixed(2);
366
467
  logger.debug(`[Scorm12Driver] Diet suspend_data: ${compressedSizeKB}KB compressed`);
@@ -369,6 +470,9 @@ export class Scorm12Driver extends ScormDriverBase {
369
470
  logger.error(`[Scorm12Driver] ⚠️ CRITICAL: Strict diet still exceeds 4KB! (${compressedSizeKB}KB)`);
370
471
  eventBus.emit('suspend-data:critical', { bytes: compressed.length, format: 'scorm1.2' });
371
472
  }
473
+ if (compressed.length > 4096) {
474
+ throw new Error(`SCORM 1.2 suspend_data exceeds the 4096-character limit (${compressed.length})`);
475
+ }
372
476
 
373
477
  this._rawSet('cmi.suspend_data', compressed);
374
478
  return true;
@@ -379,87 +483,11 @@ export class Scorm12Driver extends ScormDriverBase {
379
483
  // =========================================================================
380
484
 
381
485
  _createDietState(fullState) {
382
- const diet = {};
383
-
384
- // Use cached bookmark (the authoritative location)
385
- const currentSlide = this._cache.bookmark || null;
386
-
387
- if (fullState.navigation) {
388
- diet.nav = {
389
- cur: currentSlide,
390
- vis: fullState.navigation.visitedSlides
391
- };
392
- }
393
-
394
- if (fullState.accessibility) {
395
- diet.acc = fullState.accessibility;
396
- }
397
-
398
- if (fullState.flags && Object.keys(fullState.flags).length > 0) {
399
- diet.flg = fullState.flags;
400
- }
401
-
402
- if (fullState.engagement) {
403
- diet.eng = {};
404
- for (const [slideId, slideState] of Object.entries(fullState.engagement)) {
405
- diet.eng[slideId] = { c: slideState.complete ? 1 : 0 };
406
- }
407
- }
408
-
409
- if (fullState.interactionResponses && currentSlide) {
410
- if (fullState.interactionResponses[currentSlide]) {
411
- diet.int = { [currentSlide]: fullState.interactionResponses[currentSlide] };
412
- }
413
- }
414
-
415
- for (const [key, value] of Object.entries(fullState)) {
416
- if (key.startsWith('assessment_')) {
417
- diet[`as_${key.substring(11)}`] = value;
418
- }
419
- }
420
-
421
- return diet;
486
+ return createScorm12DietState(fullState, this._cache.bookmark || null);
422
487
  }
423
488
 
424
489
  _expandDietState(dietState) {
425
- const expanded = {};
426
-
427
- if (dietState.nav) {
428
- expanded.navigation = {
429
- currentSlide: dietState.nav.cur,
430
- visitedSlides: dietState.nav.vis || []
431
- };
432
- }
433
-
434
- if (dietState.acc) {
435
- expanded.accessibility = dietState.acc;
436
- }
437
-
438
- if (dietState.flg) {
439
- expanded.flags = dietState.flg;
440
- }
441
-
442
- if (dietState.eng) {
443
- expanded.engagement = {};
444
- for (const [slideId, slideState] of Object.entries(dietState.eng)) {
445
- expanded.engagement[slideId] = {
446
- complete: slideState.c === 1,
447
- tracked: {}
448
- };
449
- }
450
- }
451
-
452
- if (dietState.int) {
453
- expanded.interactionResponses = dietState.int;
454
- }
455
-
456
- for (const [key, value] of Object.entries(dietState)) {
457
- if (key.startsWith('as_')) {
458
- expanded[`assessment_${key.substring(3)}`] = value;
459
- }
460
- }
461
-
462
- return expanded;
490
+ return expandScorm12DietState(dietState);
463
491
  }
464
492
 
465
493
  // =========================================================================
@@ -486,32 +514,81 @@ export class Scorm12Driver extends ScormDriverBase {
486
514
  const lessonStatus = this._scorm.get('cmi.core.lesson_status');
487
515
  this._statusCache = mapStatusTo2004(lessonStatus);
488
516
 
517
+ const scoreChildren = this._readChildren('cmi.core.score._children');
518
+ this._scoreChildren = new Set(['raw', ...scoreChildren]);
519
+
520
+ const topLevelChildren = this._readChildren('cmi._children');
521
+ this._supportsObjectives = topLevelChildren.has('objectives');
522
+ this._supportsInteractions = topLevelChildren.has('interactions');
523
+
489
524
  // Read interactions count for append tracking
490
525
  try {
491
- const parsed = parseInt(this._scorm.get('cmi.interactions._count') || '0', 10);
492
- this._cache.interactionsCount = isNaN(parsed) ? 0 : parsed;
526
+ if (!this._supportsInteractions) throw new Error('interactions unsupported');
527
+ this._cache.interactionsCount = parseNonNegativeInteger(
528
+ this._scorm.get('cmi.interactions._count') || '0'
529
+ );
493
530
  } catch (_e) {
494
531
  this._cache.interactionsCount = 0;
495
532
  }
533
+
534
+ this._objectiveIdToIndex.clear();
535
+ try {
536
+ if (!this._supportsObjectives) throw new Error('objectives unsupported');
537
+ this._objectivesCount = parseNonNegativeInteger(
538
+ this._scorm.get('cmi.objectives._count') || '0'
539
+ );
540
+ } catch (_e) {
541
+ this._objectivesCount = 0;
542
+ }
543
+ for (let i = 0; i < this._objectivesCount; i++) {
544
+ try {
545
+ const id = this._scorm.get(`cmi.objectives.${i}.id`) || '';
546
+ if (id) this._objectiveIdToIndex.set(id, i);
547
+ } catch (_e) {
548
+ // Preserve the LMS-reported count even if one optional row is unreadable.
549
+ }
550
+ }
496
551
  }
497
552
 
498
553
  /**
499
554
  * Objective index tracking (same pattern as SCORM 2004 but 1.2-native).
500
555
  */
501
- _objectiveIdToIndex = new Map();
502
-
503
556
  _getOrCreateObjectiveIndex(objectiveId) {
504
557
  if (this._objectiveIdToIndex.has(objectiveId)) {
505
558
  return this._objectiveIdToIndex.get(objectiveId);
506
559
  }
507
560
 
508
- const newIndex = this._objectiveIdToIndex.size;
561
+ const newIndex = this._objectivesCount;
509
562
  this._rawSet(`cmi.objectives.${newIndex}.id`, objectiveId);
510
563
  this._objectiveIdToIndex.set(objectiveId, newIndex);
564
+ this._objectivesCount++;
511
565
 
512
566
  return newIndex;
513
567
  }
514
568
 
569
+ _readChildren(key) {
570
+ try {
571
+ return new Set(
572
+ String(this._scorm.get(key) || '')
573
+ .split(',')
574
+ .map(value => value.trim())
575
+ .filter(Boolean)
576
+ );
577
+ } catch (_error) {
578
+ return new Set();
579
+ }
580
+ }
581
+
582
+ _rawGet(key12) {
583
+ const value = this._scorm.get(key12);
584
+ const code = Number(this._scorm.debug?.getCode?.() || 0);
585
+ if (code !== 0) {
586
+ const info = this._scorm.debug?.getInfo?.(code) || `SCORM error ${code}`;
587
+ throw new Error(`Failed to get value for "${key12}": ${info}`);
588
+ }
589
+ return value || '';
590
+ }
591
+
515
592
  /**
516
593
  * Low-level raw SCORM 1.2 set. No mapping, no translation.
517
594
  */
@@ -547,7 +624,7 @@ export class Scorm12Driver extends ScormDriverBase {
547
624
  const success = this._scorm.set('cmi.core.lesson_status', lessonStatus);
548
625
 
549
626
  if (!success) {
550
- logger.warn(`[Scorm12Driver] Failed to sync lesson_status to: ${lessonStatus}`);
627
+ throw new Error(`[Scorm12Driver] Failed to sync lesson_status to: ${lessonStatus}`);
551
628
  }
552
629
  }
553
630
  }
@@ -556,5 +633,11 @@ export class Scorm12Driver extends ScormDriverBase {
556
633
  export {
557
634
  mapStatusTo12,
558
635
  mapStatusTo2004,
559
- convertTimeFormat2004To12
636
+ mapObjectiveStatusTo12,
637
+ convertTimeFormat2004To12,
638
+ convertTimestamp2004To12,
639
+ createScorm12DietState,
640
+ expandScorm12DietState,
641
+ encodeScorm12SuspendState,
642
+ decodeScorm12SuspendState
560
643
  };
@@ -13,6 +13,17 @@ import { eventBus } from '../core/event-bus.js';
13
13
  import { logger } from '../utilities/logger.js';
14
14
  import LZString from 'lz-string';
15
15
 
16
+ function parseFiniteNumber(value, fallback = null) {
17
+ if (value === null || value === undefined || String(value).trim() === '') return fallback;
18
+ const parsed = Number(value);
19
+ return Number.isFinite(parsed) ? parsed : fallback;
20
+ }
21
+
22
+ function parseNonNegativeInteger(value) {
23
+ const parsed = Number(value);
24
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
25
+ }
26
+
16
27
  /**
17
28
  * SCORM 2004 4th Edition Driver
18
29
  * Communicates with window.API_1484_11 via the pipwerks SCORM wrapper.
@@ -32,6 +43,7 @@ export class Scorm2004Driver extends ScormDriverBase {
32
43
  learnerName: '',
33
44
  objectives: {}, // Cached objectives keyed by ID
34
45
  objectiveIdToIndex: new Map(), // Maps objective ID → CMI index
46
+ objectivesCount: 0, // LMS-reported count; may exceed readable IDs
35
47
  interactions: [], // Cached interactions array
36
48
  interactionsCount: 0 // Current count for append operations
37
49
  };
@@ -50,7 +62,7 @@ export class Scorm2004Driver extends ScormDriverBase {
50
62
  supportsObjectives: true,
51
63
  supportsInteractions: true,
52
64
  supportsComments: true,
53
- supportsEmergencySave: false,
65
+ supportsEmergencySave: true,
54
66
  maxSuspendDataBytes: 64000,
55
67
  asyncCommit: false
56
68
  };
@@ -217,17 +229,16 @@ export class Scorm2004Driver extends ScormDriverBase {
217
229
 
218
230
  getScore() {
219
231
  const scaledStr = this._getValueOptional('cmi.score.scaled');
220
- if (scaledStr === null) return null;
221
- const scaled = parseFloat(scaledStr);
222
- if (isNaN(scaled)) return null;
232
+ const scaled = parseFiniteNumber(scaledStr);
233
+ if (scaled === null) return null;
223
234
  const rawStr = this._getValueOptional('cmi.score.raw');
224
235
  const minStr = this._getValueOptional('cmi.score.min');
225
236
  const maxStr = this._getValueOptional('cmi.score.max');
226
237
  return {
227
238
  scaled,
228
- raw: rawStr !== null ? parseFloat(rawStr) : scaled * 100,
229
- min: minStr !== null ? parseFloat(minStr) : 0,
230
- max: maxStr !== null ? parseFloat(maxStr) : 100
239
+ raw: parseFiniteNumber(rawStr, scaled * 100),
240
+ min: parseFiniteNumber(minStr, 0),
241
+ max: parseFiniteNumber(maxStr, 100)
231
242
  };
232
243
  }
233
244
 
@@ -386,8 +397,7 @@ export class Scorm2004Driver extends ScormDriverBase {
386
397
  // Decompress lz-string data
387
398
  const jsonString = LZString.decompressFromUTF16(data);
388
399
  if (!jsonString) {
389
- logger.warn('[Scorm2004Driver] Failed to decompress suspend_data - may be corrupted');
390
- return null;
400
+ throw new Error('SCORM 2004 resume state is corrupted or truncated; refusing to start with empty progress');
391
401
  }
392
402
 
393
403
  try {
@@ -457,6 +467,7 @@ export class Scorm2004Driver extends ScormDriverBase {
457
467
  if (compressed.length > 64000) {
458
468
  logger.error(`[Scorm2004Driver] ⚠️ CRITICAL: suspend_data is ${compressedSizeKB}KB compressed (over 64KB). Many LMSs will reject this!`);
459
469
  eventBus.emit('suspend-data:critical', { bytes: compressed.length });
470
+ throw new Error(`SCORM 2004 suspend_data exceeds the 64000-character safety limit (${compressed.length})`);
460
471
  } else if (compressed.length > 32000) {
461
472
  logger.warn(`[Scorm2004Driver] ⚠️ WARNING: suspend_data is ${compressedSizeKB}KB compressed (over 32KB). Approaching critical threshold.`);
462
473
  eventBus.emit('suspend-data:warning', { bytes: compressed.length });
@@ -585,8 +596,9 @@ export class Scorm2004Driver extends ScormDriverBase {
585
596
  // Load objectives from CMI arrays
586
597
  let objectivesCount = 0;
587
598
  try {
588
- objectivesCount = parseInt(this._getValue('cmi.objectives._count') || '0', 10);
599
+ objectivesCount = parseNonNegativeInteger(this._getValue('cmi.objectives._count') || '0');
589
600
  } catch (_e) { /* No objectives stored — normal */ }
601
+ this._cmiCache.objectivesCount = objectivesCount;
590
602
 
591
603
  for (let i = 0; i < objectivesCount; i++) {
592
604
  let id;
@@ -610,16 +622,16 @@ export class Scorm2004Driver extends ScormDriverBase {
610
622
 
611
623
  const scoreRaw = this._getValueOptional(`cmi.objectives.${i}.score.raw`);
612
624
  if (scoreRaw) {
613
- const parsed = parseFloat(scoreRaw);
614
- if (!isNaN(parsed)) {
625
+ const parsed = parseFiniteNumber(scoreRaw);
626
+ if (parsed !== null) {
615
627
  this._cmiCache.objectives[id].score = parsed;
616
628
  }
617
629
  }
618
630
 
619
631
  const progressMeasure = this._getValueOptional(`cmi.objectives.${i}.progress_measure`);
620
632
  if (progressMeasure) {
621
- const parsed = parseFloat(progressMeasure);
622
- if (!isNaN(parsed)) {
633
+ const parsed = parseFiniteNumber(progressMeasure);
634
+ if (parsed !== null) {
623
635
  this._cmiCache.objectives[id].progress_measure = parsed;
624
636
  }
625
637
  }
@@ -633,9 +645,9 @@ export class Scorm2004Driver extends ScormDriverBase {
633
645
  // Load interactions from CMI arrays
634
646
  let interactionsCount = 0;
635
647
  try {
636
- interactionsCount = parseInt(this._getValue('cmi.interactions._count') || '0', 10);
648
+ interactionsCount = parseNonNegativeInteger(this._getValue('cmi.interactions._count') || '0');
637
649
  } catch (_e) { /* No interactions stored — normal */ }
638
- this._cmiCache.interactionsCount = isNaN(interactionsCount) ? 0 : interactionsCount;
650
+ this._cmiCache.interactionsCount = interactionsCount;
639
651
 
640
652
  for (let i = 0; i < interactionsCount; i++) {
641
653
  const interaction = { _index: i };
@@ -650,8 +662,8 @@ export class Scorm2004Driver extends ScormDriverBase {
650
662
 
651
663
  const weighting = this._getValueOptional(`cmi.interactions.${i}.weighting`);
652
664
  if (weighting) {
653
- const parsed = parseFloat(weighting);
654
- if (!isNaN(parsed)) {
665
+ const parsed = parseFiniteNumber(weighting);
666
+ if (parsed !== null) {
655
667
  interaction.weighting = parsed;
656
668
  }
657
669
  }
@@ -673,9 +685,10 @@ export class Scorm2004Driver extends ScormDriverBase {
673
685
  return this._cmiCache.objectiveIdToIndex.get(objectiveId);
674
686
  }
675
687
 
676
- const newIndex = this._cmiCache.objectiveIdToIndex.size;
688
+ const newIndex = this._cmiCache.objectivesCount;
677
689
  this._setValue(`cmi.objectives.${newIndex}.id`, objectiveId);
678
690
  this._cmiCache.objectiveIdToIndex.set(objectiveId, newIndex);
691
+ this._cmiCache.objectivesCount++;
679
692
 
680
693
  return newIndex;
681
694
  }
@@ -109,4 +109,16 @@ export class ScormDriverBase {
109
109
 
110
110
  return true;
111
111
  }
112
+
113
+ /**
114
+ * Synchronous best-effort commit for pagehide. StateManager writes the
115
+ * latest suspend_data and exit mode immediately before this call.
116
+ */
117
+ emergencySave() {
118
+ if (!this._isConnected || this._isTerminated) return;
119
+ const success = this._scorm.save();
120
+ if (!success) {
121
+ throw new Error(`[${this.constructor.name}] SCORM emergency commit failed`);
122
+ }
123
+ }
112
124
  }