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
@@ -137,16 +137,16 @@ class EventBus {
137
137
 
138
138
  // Create a copy of listeners to avoid issues if listeners modify the array
139
139
  const listeners = [...this.events[event]];
140
- const onceListeners = [];
141
140
 
142
141
  listeners.forEach(listener => {
142
+ // Remove one-shot listeners before invocation so thrown callbacks and
143
+ // re-entrant emits cannot execute them more than once.
144
+ if (listener.once) {
145
+ this.off(event, listener.id);
146
+ }
147
+
143
148
  try {
144
149
  listener.callback(data);
145
-
146
- // Track once listeners for removal
147
- if (listener.once) {
148
- onceListeners.push(listener.id);
149
- }
150
150
  } catch (error) {
151
151
  // Log the error but don't break other listeners — use safeStringify
152
152
  // to prevent a secondary cascade from unserializable error objects
@@ -154,8 +154,6 @@ class EventBus {
154
154
  }
155
155
  });
156
156
 
157
- // Remove once listeners
158
- onceListeners.forEach(id => this.off(event, id));
159
157
  } finally {
160
158
  if (isErrorEvent) {
161
159
  this._emittingError = false;
@@ -177,37 +175,42 @@ class EventBus {
177
175
  const hasListeners = Boolean(this.events[event]?.length);
178
176
  if (!isErrorEvent && !hasListeners) return false;
179
177
 
178
+ if (isErrorEvent && this._emittingError) {
179
+ logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
180
+ return false;
181
+ }
182
+
180
183
  if (isErrorEvent) {
181
- if (this._emittingError) {
182
- logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
183
- return false;
184
- }
185
184
  this._emittingError = true;
186
- logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
187
- this._emittingError = false;
188
185
  }
189
186
 
190
- if (!hasListeners) return false;
187
+ try {
188
+ if (isErrorEvent) {
189
+ logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
190
+ }
191
191
 
192
- const listeners = [...this.events[event]];
193
- const onceListeners = [];
192
+ if (!hasListeners) return false;
194
193
 
195
- for (const listener of listeners) {
196
- try {
197
- await listener.callback(data);
194
+ const listeners = [...this.events[event]];
198
195
 
196
+ for (const listener of listeners) {
199
197
  if (listener.once) {
200
- onceListeners.push(listener.id);
198
+ this.off(event, listener.id);
201
199
  }
202
- } catch (error) {
203
- logger.error(`[EventBus] Error in async listener for '${event}':`, error);
204
- }
205
- }
206
200
 
207
- // Remove once listeners
208
- onceListeners.forEach(id => this.off(event, id));
201
+ try {
202
+ await listener.callback(data);
203
+ } catch (error) {
204
+ logger.error(`[EventBus] Error in async listener for '${event}':`, error);
205
+ }
206
+ }
209
207
 
210
- return true;
208
+ return true;
209
+ } finally {
210
+ if (isErrorEvent) {
211
+ this._emittingError = false;
212
+ }
213
+ }
211
214
  }
212
215
 
213
216
  /**
@@ -13,7 +13,7 @@
13
13
  * - xAPI statement methods (objectives, interactions, assessments, slides)
14
14
  * - suspend_data persistence via xAPI State API
15
15
  * - Bookmark persistence via xAPI State API
16
- * - Emergency save via sendBeacon
16
+ * - Emergency save via authenticated keepalive fetch
17
17
  */
18
18
 
19
19
  import { HttpDriverBase } from './http-driver-base.js';
@@ -37,6 +37,8 @@ export class Cmi5Driver extends HttpDriverBase {
37
37
  // Track what statements we've sent (persisted for resume)
38
38
  this._sentComplete = false;
39
39
  this._sentResult = false;
40
+ this._sentSuccessStatus = null;
41
+ this._outcomeDirty = false;
40
42
  }
41
43
 
42
44
  // =========================================================================
@@ -161,28 +163,7 @@ export class Cmi5Driver extends HttpDriverBase {
161
163
 
162
164
  try {
163
165
  await this._persistState();
164
-
165
- if (this._completionStatus === 'completed' && !this._sentComplete) {
166
- await this._cmi5.complete();
167
- this._sentComplete = true;
168
- logger.debug('[Cmi5Driver] Sent Completed statement');
169
- }
170
-
171
- if (!this._sentResult) {
172
- const scoreObj = this._score !== null ? { scaled: this._score } : undefined;
173
-
174
- if (this._successStatus === 'passed') {
175
- await this._cmi5.pass(scoreObj);
176
- this._sentResult = true;
177
- logger.debug('[Cmi5Driver] Sent Passed statement');
178
- } else if (this._successStatus === 'failed') {
179
- await this._cmi5.fail(scoreObj);
180
- this._sentResult = true;
181
- logger.debug('[Cmi5Driver] Sent Failed statement');
182
- }
183
- }
184
-
185
- await this._persistSentFlags();
166
+ await this._flushOutcomeStatements();
186
167
  await this._cmi5.terminate();
187
168
  logger.debug('[Cmi5Driver] Sent Terminated statement');
188
169
 
@@ -196,7 +177,7 @@ export class Cmi5Driver extends HttpDriverBase {
196
177
  }
197
178
 
198
179
  /**
199
- * Emergency save using sendBeacon for page unload scenarios.
180
+ * Emergency save using authenticated keepalive requests for page unload scenarios.
200
181
  */
201
182
  emergencySave() {
202
183
  if (this._mock || this._isTerminated) {
@@ -218,6 +199,16 @@ export class Cmi5Driver extends HttpDriverBase {
218
199
  }
219
200
 
220
201
  const endpoint = params.endpoint?.replace(/\/$/, '');
202
+ const authToken = this._cmi5.getAuthToken?.();
203
+ if (!endpoint || !authToken) {
204
+ logger.warn('[Cmi5Driver] Emergency save unavailable because the authenticated LRS connection is incomplete', {
205
+ domain: 'cmi5',
206
+ operation: 'emergencySave',
207
+ hasEndpoint: Boolean(endpoint),
208
+ hasAuthToken: Boolean(authToken)
209
+ });
210
+ return;
211
+ }
221
212
  const registration = params.registration;
222
213
  const activityId = encodeURIComponent(params.activityId);
223
214
  const agent = encodeURIComponent(JSON.stringify(params.actor));
@@ -225,14 +216,7 @@ export class Cmi5Driver extends HttpDriverBase {
225
216
 
226
217
  if (this._suspendDataDirty && this._suspendDataCache !== null) {
227
218
  const suspendDataUrl = `${endpoint}/activities/state?stateId=${stateId}&activityId=${activityId}&agent=${agent}&registration=${registration}`;
228
- const suspendDataBlob = new Blob([JSON.stringify(this._suspendDataCache)], { type: 'application/json' });
229
-
230
- const suspendSent = navigator.sendBeacon(suspendDataUrl, suspendDataBlob);
231
- if (suspendSent) {
232
- logger.debug('[Cmi5Driver] Emergency save: suspend_data sent via sendBeacon');
233
- } else {
234
- logger.warn('[Cmi5Driver] Emergency save: sendBeacon failed for suspend_data');
235
- }
219
+ this._sendAuthenticatedKeepalive(suspendDataUrl, this._suspendDataCache, authToken, 'suspend_data');
236
220
  }
237
221
 
238
222
  if (this._bookmarkDirty) {
@@ -244,19 +228,35 @@ export class Cmi5Driver extends HttpDriverBase {
244
228
  successStatus: this._successStatus,
245
229
  score: this._score,
246
230
  sentComplete: this._sentComplete,
247
- sentResult: this._sentResult
231
+ sentResult: this._sentResult,
232
+ sentSuccessStatus: this._sentSuccessStatus
248
233
  };
249
- const bookmarkBlob = new Blob([JSON.stringify(bookmarkData)], { type: 'application/json' });
250
-
251
- const bookmarkSent = navigator.sendBeacon(bookmarkUrl, bookmarkBlob);
252
- if (bookmarkSent) {
253
- logger.debug('[Cmi5Driver] Emergency save: bookmark sent via sendBeacon');
254
- } else {
255
- logger.warn('[Cmi5Driver] Emergency save: sendBeacon failed for bookmark');
256
- }
234
+ this._sendAuthenticatedKeepalive(bookmarkUrl, bookmarkData, authToken, 'bookmark');
257
235
  }
258
236
  }
259
237
 
238
+ async commit() {
239
+ const result = await super.commit();
240
+ if (!result || this._mock) return result;
241
+ await this._flushOutcomeStatements();
242
+ return true;
243
+ }
244
+
245
+ reportScore(score) {
246
+ super.reportScore(score);
247
+ this._outcomeDirty = true;
248
+ }
249
+
250
+ reportCompletion(status) {
251
+ super.reportCompletion(status);
252
+ this._outcomeDirty = true;
253
+ }
254
+
255
+ reportSuccess(status) {
256
+ super.reportSuccess(status);
257
+ this._outcomeDirty = true;
258
+ }
259
+
260
260
  // =========================================================================
261
261
  // Semantic Reads (override)
262
262
  // =========================================================================
@@ -324,7 +324,6 @@ export class Cmi5Driver extends HttpDriverBase {
324
324
  }
325
325
 
326
326
  const params = this._cmi5.getLaunchParameters();
327
- const xapi = this._cmi5.xapi;
328
327
 
329
328
  const verbMap = {
330
329
  'completed': 'http://adlnet.gov/expapi/verbs/completed',
@@ -337,26 +336,22 @@ export class Cmi5Driver extends HttpDriverBase {
337
336
  const verbDisplay = data.verb || 'progressed';
338
337
 
339
338
  const statement = {
339
+ actor: params.actor,
340
340
  verb: {
341
341
  id: verbId,
342
342
  display: { 'en-US': verbDisplay }
343
343
  },
344
344
  object: {
345
- id: `${params.activityId}/objectives/${data.id}`,
345
+ id: this._activityObjectId(params.activityId, 'objectives', data.id),
346
346
  definition: {
347
347
  type: 'http://adlnet.gov/expapi/activities/objective',
348
348
  name: data.name ? { 'en-US': data.name } : { 'en-US': data.id }
349
349
  }
350
350
  },
351
- context: {
352
- registration: params.registration,
353
- contextActivities: {
354
- parent: [{ id: params.activityId }]
355
- }
356
- }
351
+ context: this._buildAllowedStatementContext(params)
357
352
  };
358
353
 
359
- if (data.score !== undefined || data.duration) {
354
+ if (data.score !== undefined || data.duration || ['completed', 'passed', 'failed'].includes(data.verb)) {
360
355
  statement.result = {};
361
356
  if (data.score !== undefined) {
362
357
  statement.result.score = { scaled: data.score };
@@ -373,7 +368,7 @@ export class Cmi5Driver extends HttpDriverBase {
373
368
  }
374
369
 
375
370
  try {
376
- await xapi.sendStatement(statement);
371
+ await this._cmi5.sendXapiStatement(statement);
377
372
  logger.debug(`[Cmi5Driver] Sent objective statement: ${data.verb} for ${data.id}`);
378
373
  } catch (error) {
379
374
  logger.error('[Cmi5Driver] Failed to send objective statement:', error);
@@ -396,7 +391,6 @@ export class Cmi5Driver extends HttpDriverBase {
396
391
  }
397
392
 
398
393
  const params = this._cmi5.getLaunchParameters();
399
- const xapi = this._cmi5.xapi;
400
394
 
401
395
  const interactionTypeMap = {
402
396
  'choice': 'choice',
@@ -412,12 +406,13 @@ export class Cmi5Driver extends HttpDriverBase {
412
406
  };
413
407
 
414
408
  const statement = {
409
+ actor: params.actor,
415
410
  verb: {
416
411
  id: 'http://adlnet.gov/expapi/verbs/answered',
417
412
  display: { 'en-US': 'answered' }
418
413
  },
419
414
  object: {
420
- id: `${params.activityId}/interactions/${data.id}`,
415
+ id: this._activityObjectId(params.activityId, 'interactions', data.id),
421
416
  definition: {
422
417
  type: 'http://adlnet.gov/expapi/activities/cmi.interaction',
423
418
  interactionType: interactionTypeMap[data.type] || 'other'
@@ -427,12 +422,7 @@ export class Cmi5Driver extends HttpDriverBase {
427
422
  response: String(data.response),
428
423
  success: data.correct
429
424
  },
430
- context: {
431
- registration: params.registration,
432
- contextActivities: {
433
- parent: [{ id: params.activityId }]
434
- }
435
- }
425
+ context: this._buildAllowedStatementContext(params)
436
426
  };
437
427
 
438
428
  if (data.description) {
@@ -445,13 +435,13 @@ export class Cmi5Driver extends HttpDriverBase {
445
435
 
446
436
  if (data.objectiveId) {
447
437
  statement.context.contextActivities.other = [{
448
- id: `${params.activityId}/objectives/${data.objectiveId}`,
438
+ id: this._activityObjectId(params.activityId, 'objectives', data.objectiveId),
449
439
  definition: { type: 'http://adlnet.gov/expapi/activities/objective' }
450
440
  }];
451
441
  }
452
442
 
453
443
  try {
454
- await xapi.sendStatement(statement);
444
+ await this._cmi5.sendXapiStatement(statement);
455
445
  logger.debug(`[Cmi5Driver] Sent interaction statement: ${data.id} (${data.correct ? 'correct' : 'incorrect'})`);
456
446
  } catch (error) {
457
447
  logger.error('[Cmi5Driver] Failed to send interaction statement:', error);
@@ -470,15 +460,15 @@ export class Cmi5Driver extends HttpDriverBase {
470
460
  }
471
461
 
472
462
  const params = this._cmi5.getLaunchParameters();
473
- const xapi = this._cmi5.xapi;
474
463
 
475
464
  const statement = {
465
+ actor: params.actor,
476
466
  verb: {
477
467
  id: 'http://adlnet.gov/expapi/verbs/completed',
478
468
  display: { 'en-US': 'completed' }
479
469
  },
480
470
  object: {
481
- id: `${params.activityId}/assessments/${data.id}`,
471
+ id: this._activityObjectId(params.activityId, 'assessments', data.id),
482
472
  definition: {
483
473
  type: 'http://adlnet.gov/expapi/activities/assessment',
484
474
  name: data.name ? { 'en-US': data.name } : { 'en-US': data.id }
@@ -494,15 +484,11 @@ export class Cmi5Driver extends HttpDriverBase {
494
484
  success: data.passed,
495
485
  completion: true
496
486
  },
497
- context: {
498
- registration: params.registration,
499
- contextActivities: {
500
- parent: [{ id: params.activityId }]
501
- },
487
+ context: this._buildAllowedStatementContext(params, {
502
488
  extensions: {
503
489
  'https://w3id.org/xapi/cmi5/context/extensions/attemptNumber': data.attemptNumber
504
490
  }
505
- }
491
+ })
506
492
  };
507
493
 
508
494
  if (data.duration) {
@@ -510,7 +496,7 @@ export class Cmi5Driver extends HttpDriverBase {
510
496
  }
511
497
 
512
498
  try {
513
- await xapi.sendStatement(statement);
499
+ await this._cmi5.sendXapiStatement(statement);
514
500
  logger.debug(`[Cmi5Driver] Sent assessment statement: ${data.id} (${data.passed ? 'passed' : 'failed'}) attempt ${data.attemptNumber}`);
515
501
  } catch (error) {
516
502
  logger.error('[Cmi5Driver] Failed to send assessment statement:', error);
@@ -529,26 +515,21 @@ export class Cmi5Driver extends HttpDriverBase {
529
515
  }
530
516
 
531
517
  const params = this._cmi5.getLaunchParameters();
532
- const xapi = this._cmi5.xapi;
533
518
 
534
519
  const statement = {
520
+ actor: params.actor,
535
521
  verb: {
536
522
  id: 'http://adlnet.gov/expapi/verbs/experienced',
537
523
  display: { 'en-US': 'experienced' }
538
524
  },
539
525
  object: {
540
- id: `${params.activityId}/slides/${data.id}`,
526
+ id: this._activityObjectId(params.activityId, 'slides', data.id),
541
527
  definition: {
542
528
  type: 'http://adlnet.gov/expapi/activities/media',
543
529
  name: data.title ? { 'en-US': data.title } : { 'en-US': data.id }
544
530
  }
545
531
  },
546
- context: {
547
- registration: params.registration,
548
- contextActivities: {
549
- parent: [{ id: params.activityId }]
550
- }
551
- }
532
+ context: this._buildAllowedStatementContext(params)
552
533
  };
553
534
 
554
535
  if (data.duration) {
@@ -558,7 +539,7 @@ export class Cmi5Driver extends HttpDriverBase {
558
539
  }
559
540
 
560
541
  try {
561
- await xapi.sendStatement(statement);
542
+ await this._cmi5.sendXapiStatement(statement);
562
543
  logger.debug(`[Cmi5Driver] Sent slide statement: experienced ${data.id} (${data.duration || 'no duration'})`);
563
544
  } catch (error) {
564
545
  logger.error('[Cmi5Driver] Failed to send slide statement:', error);
@@ -623,7 +604,7 @@ export class Cmi5Driver extends HttpDriverBase {
623
604
  }
624
605
  } catch (error) {
625
606
  if (error.response?.status !== 404) {
626
- logger.warn('[Cmi5Driver] Error fetching suspend_data:', error.message);
607
+ throw new Error(`Unable to load suspend_data: ${error.message}`);
627
608
  }
628
609
  this._suspendDataCache = null;
629
610
  }
@@ -643,11 +624,15 @@ export class Cmi5Driver extends HttpDriverBase {
643
624
  this._score = bookmarkData.score ?? null;
644
625
  this._sentComplete = bookmarkData.sentComplete || false;
645
626
  this._sentResult = bookmarkData.sentResult || false;
627
+ this._sentSuccessStatus = bookmarkData.sentSuccessStatus ||
628
+ (this._sentResult && ['passed', 'failed'].includes(this._successStatus)
629
+ ? this._successStatus
630
+ : null);
646
631
  logger.debug('[Cmi5Driver] Loaded bookmark from State API:', this._bookmarkCache);
647
632
  }
648
633
  } catch (error) {
649
634
  if (error.response?.status !== 404) {
650
- logger.warn('[Cmi5Driver] Error fetching bookmark:', error.message);
635
+ throw new Error(`Unable to load bookmark state: ${error.message}`);
651
636
  }
652
637
  }
653
638
  }
@@ -695,7 +680,8 @@ export class Cmi5Driver extends HttpDriverBase {
695
680
  successStatus: this._successStatus,
696
681
  score: this._score,
697
682
  sentComplete: this._sentComplete,
698
- sentResult: this._sentResult
683
+ sentResult: this._sentResult,
684
+ sentSuccessStatus: this._sentSuccessStatus
699
685
  }
700
686
  });
701
687
  logger.debug('[Cmi5Driver] Persisted bookmark');
@@ -708,6 +694,114 @@ export class Cmi5Driver extends HttpDriverBase {
708
694
  async _persistSentFlags() {
709
695
  this._bookmarkDirty = true;
710
696
  await this._persistBookmark();
697
+ this._bookmarkDirty = false;
698
+ }
699
+
700
+ _buildAllowedStatementContext(params, additions = {}) {
701
+ const launchData = this._cmi5.getLaunchData?.() || {};
702
+ const template = launchData.contextTemplate || {};
703
+ const templateActivities = template.contextActivities || {};
704
+ const additionalActivities = additions.contextActivities || {};
705
+ const existingParents = [
706
+ ...(templateActivities.parent || []),
707
+ ...(additionalActivities.parent || [])
708
+ ];
709
+ if (!existingParents.some(activity => activity?.id === params.activityId)) {
710
+ existingParents.push({ id: params.activityId });
711
+ }
712
+
713
+ return {
714
+ ...template,
715
+ ...additions,
716
+ registration: params.registration,
717
+ contextActivities: {
718
+ ...templateActivities,
719
+ ...additionalActivities,
720
+ parent: existingParents
721
+ },
722
+ extensions: {
723
+ ...(template.extensions || {}),
724
+ ...(additions.extensions || {})
725
+ }
726
+ };
727
+ }
728
+
729
+ _activityObjectId(activityId, collection, itemId) {
730
+ const base = String(activityId).replace(/\/$/, '');
731
+ return `${base}/${collection}/${encodeURIComponent(String(itemId))}`;
732
+ }
733
+
734
+ async _flushOutcomeStatements() {
735
+ const pendingCompletion = this._completionStatus === 'completed' && !this._sentComplete;
736
+ const hasResult = ['passed', 'failed'].includes(this._successStatus);
737
+ const pendingResult = hasResult && (
738
+ !this._sentResult ||
739
+ (this._sentSuccessStatus !== null && this._sentSuccessStatus !== this._successStatus)
740
+ );
741
+ if (this._mock || !this._cmi5 || (!this._outcomeDirty && !pendingCompletion && !pendingResult)) return;
742
+
743
+ const launchMode = this._cmi5.getLaunchData?.()?.launchMode || 'Normal';
744
+ if (launchMode !== 'Normal') {
745
+ this._outcomeDirty = false;
746
+ return;
747
+ }
748
+
749
+ let changed = false;
750
+ if (this._completionStatus === 'completed' && !this._sentComplete) {
751
+ await this._cmi5.complete();
752
+ this._sentComplete = true;
753
+ changed = true;
754
+ logger.debug('[Cmi5Driver] Sent Completed statement');
755
+ }
756
+
757
+ if (pendingResult) {
758
+ const scoreObj = this._score !== null ? { scaled: this._score } : undefined;
759
+ if (this._successStatus === 'passed') {
760
+ await this._cmi5.pass(scoreObj);
761
+ this._sentResult = true;
762
+ this._sentSuccessStatus = 'passed';
763
+ changed = true;
764
+ logger.debug('[Cmi5Driver] Sent Passed statement');
765
+ } else if (this._successStatus === 'failed') {
766
+ await this._cmi5.fail(scoreObj);
767
+ this._sentResult = true;
768
+ this._sentSuccessStatus = 'failed';
769
+ changed = true;
770
+ logger.debug('[Cmi5Driver] Sent Failed statement');
771
+ }
772
+ }
773
+
774
+ this._outcomeDirty = false;
775
+ if (changed) await this._persistSentFlags();
776
+ }
777
+
778
+ _sendAuthenticatedKeepalive(url, state, authToken, label) {
779
+ const authorization = /^(Basic|Bearer)\s/i.test(authToken)
780
+ ? authToken
781
+ : `Basic ${authToken}`;
782
+ fetch(url, {
783
+ method: 'PUT',
784
+ headers: {
785
+ 'Authorization': authorization,
786
+ 'X-Experience-API-Version': '1.0.3',
787
+ 'Content-Type': 'application/json'
788
+ },
789
+ body: JSON.stringify(state),
790
+ keepalive: true
791
+ }).then(response => {
792
+ if (!response.ok) {
793
+ throw new Error(`${response.status} ${response.statusText || ''}`.trim());
794
+ }
795
+ logger.debug(`[Cmi5Driver] Emergency save: ${label} persisted`);
796
+ }).catch(error => {
797
+ logger.error(`[Cmi5Driver] Emergency save failed for ${label}`, {
798
+ domain: 'cmi5',
799
+ operation: 'emergencySave',
800
+ label,
801
+ error: error.message,
802
+ stack: error.stack
803
+ });
804
+ });
711
805
  }
712
806
 
713
807
  // =========================================================================
@@ -726,6 +820,10 @@ export class Cmi5Driver extends HttpDriverBase {
726
820
  this._score = state.score ?? null;
727
821
  this._sentComplete = state.sentComplete || false;
728
822
  this._sentResult = state.sentResult || false;
823
+ this._sentSuccessStatus = state.sentSuccessStatus ||
824
+ (this._sentResult && ['passed', 'failed'].includes(this._successStatus)
825
+ ? this._successStatus
826
+ : null);
729
827
  }
730
828
  this._mockState.suspendData = this._devApi.getState('suspend_data') || null;
731
829
  return;
@@ -741,6 +839,10 @@ export class Cmi5Driver extends HttpDriverBase {
741
839
  this._score = parsed.score ?? null;
742
840
  this._sentComplete = parsed.sentComplete || false;
743
841
  this._sentResult = parsed.sentResult || false;
842
+ this._sentSuccessStatus = parsed.sentSuccessStatus ||
843
+ (this._sentResult && ['passed', 'failed'].includes(this._successStatus)
844
+ ? this._successStatus
845
+ : null);
744
846
  }
745
847
  } catch (_e) {
746
848
  this._mockState = {};
@@ -756,7 +858,8 @@ export class Cmi5Driver extends HttpDriverBase {
756
858
  successStatus: this._successStatus,
757
859
  score: this._score,
758
860
  sentComplete: this._sentComplete,
759
- sentResult: this._sentResult
861
+ sentResult: this._sentResult,
862
+ sentSuccessStatus: this._sentSuccessStatus
760
863
  };
761
864
 
762
865
  if (this._devApi) {
@@ -17,7 +17,7 @@ let cachedFormat = null;
17
17
  /**
18
18
  * Creates the appropriate LMS driver based on format.
19
19
  * All drivers exist as lazy chunks; only the matching one is loaded at runtime.
20
- * @param {string} format - 'cmi5' | 'cmi5-remote' | 'scorm2004' | 'scorm1.2' | 'scorm1.2-proxy' | 'scorm2004-proxy' | 'lti'
20
+ * @param {string} format - 'standalone' | 'cmi5' | 'cmi5-remote' | 'scorm2004' | 'scorm1.2' | 'scorm1.2-proxy' | 'scorm2004-proxy' | 'lti'
21
21
  * @returns {Promise<LMSDriver>} The driver instance
22
22
  */
23
23
  export async function createDriver(format = 'cmi5') {
@@ -29,6 +29,12 @@ export async function createDriver(format = 'cmi5') {
29
29
  let driver;
30
30
 
31
31
  switch (format) {
32
+ case 'standalone': {
33
+ const { StandaloneDriver } = await import('./standalone-driver.js');
34
+ driver = new StandaloneDriver();
35
+ break;
36
+ }
37
+
32
38
  case 'scorm2004': {
33
39
  const { Scorm2004Driver } = await import('./scorm-2004-driver.js');
34
40
  driver = new Scorm2004Driver();
@@ -25,7 +25,7 @@
25
25
  * @property {function(): Promise<boolean>} terminate - Terminate connection
26
26
  * @property {function(): Promise<boolean>} commit - Commit buffered writes
27
27
  * @property {function(): DriverCapabilities} getCapabilities - Declare format capabilities
28
- * @property {function(): 'scorm2004'|'scorm1.2'|'cmi5'|'lti'} getFormat - Get format identifier
28
+ * @property {function(): 'standalone'|'scorm2004'|'scorm1.2'|'cmi5'|'lti'} getFormat - Get format identifier
29
29
  * @property {function(): boolean} isConnected - Check if driver is connected
30
30
  * @property {function(): boolean} isTerminated - Check if driver is terminated
31
31
  *
@@ -121,14 +121,20 @@ export class HttpDriverBase {
121
121
  } else if (raw !== undefined) {
122
122
  this._score = raw / 100;
123
123
  }
124
+ this._bookmarkDirty = true;
125
+ if (this._mock) this._saveMockState();
124
126
  }
125
127
 
126
128
  reportCompletion(status) {
127
129
  this._completionStatus = status;
130
+ this._bookmarkDirty = true;
131
+ if (this._mock) this._saveMockState();
128
132
  }
129
133
 
130
134
  reportSuccess(status) {
131
135
  this._successStatus = status;
136
+ this._bookmarkDirty = true;
137
+ if (this._mock) this._saveMockState();
132
138
  }
133
139
 
134
140
  reportProgress(_measure) {