coursecode 0.1.57 → 0.1.59
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 +8 -1
- package/bin/cli.js +16 -0
- package/framework/css/components/accordions.css +117 -31
- package/framework/css/layouts/article.css +7 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
- package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
- package/framework/docs/USER_GUIDE.md +14 -0
- package/framework/js/app/AppActions.js +17 -23
- package/framework/js/app/AppUI.js +7 -4
- package/framework/js/assessment/AssessmentActions.js +16 -15
- package/framework/js/assessment/AssessmentFactory.js +44 -15
- package/framework/js/automation/api-engagement.js +6 -3
- package/framework/js/components/interactions/drag-drop.js +4 -4
- package/framework/js/components/interactions/fill-in.js +7 -4
- package/framework/js/components/interactions/hotspot.js +2 -2
- package/framework/js/components/interactions/matching.js +4 -3
- package/framework/js/components/ui-components/audio-player.js +30 -8
- package/framework/js/components/ui-components/collapse.js +4 -2
- package/framework/js/components/ui-components/dropdown.js +7 -3
- package/framework/js/components/ui-components/embed-frame.js +5 -1
- package/framework/js/components/ui-components/flip-card.js +12 -6
- package/framework/js/components/ui-components/interactive-image.js +28 -52
- package/framework/js/components/ui-components/lightbox.js +19 -5
- package/framework/js/components/ui-components/modal.js +20 -7
- package/framework/js/components/ui-components/notifications.js +8 -3
- package/framework/js/components/ui-components/video-player.js +46 -20
- package/framework/js/core/event-bus.js +31 -28
- package/framework/js/dev/runtime-linter.js +6 -2
- package/framework/js/drivers/cmi5-driver.js +186 -83
- package/framework/js/drivers/driver-factory.js +7 -1
- package/framework/js/drivers/driver-interface.js +1 -1
- package/framework/js/drivers/http-driver-base.js +6 -0
- package/framework/js/drivers/lti-driver.js +112 -47
- package/framework/js/drivers/proxy-driver.js +347 -49
- package/framework/js/drivers/scorm-12-driver.js +213 -130
- package/framework/js/drivers/scorm-2004-driver.js +32 -19
- package/framework/js/drivers/scorm-driver-base.js +12 -0
- package/framework/js/drivers/standalone-driver.js +139 -0
- package/framework/js/engagement/engagement-trackers.js +38 -13
- package/framework/js/engagement/requirement-strategies.js +5 -1
- package/framework/js/main.js +7 -0
- package/framework/js/managers/assessment-manager.js +16 -1
- package/framework/js/managers/audio-manager.js +50 -16
- package/framework/js/managers/flag-manager.js +5 -1
- package/framework/js/managers/objective-manager.js +15 -7
- package/framework/js/managers/score-manager.js +27 -6
- package/framework/js/managers/video-manager.js +149 -70
- package/framework/js/navigation/NavigationActions.js +18 -4
- package/framework/js/navigation/document-gallery.js +7 -2
- package/framework/js/navigation/navigation-validators.js +3 -2
- package/framework/js/state/lms-connection.js +41 -16
- package/framework/js/state/state-commits.js +10 -5
- package/framework/js/state/state-manager.js +95 -9
- package/framework/js/state/state-validation.js +44 -11
- package/framework/js/utilities/course-helpers.js +3 -2
- package/framework/js/utilities/media-utils.js +28 -0
- package/framework/js/utilities/portable-assets.js +151 -0
- package/framework/js/utilities/ui-initializer.js +79 -5
- package/framework/js/utilities/utilities.js +32 -20
- package/framework/js/utilities/view-manager.js +16 -1
- package/framework/js/validation/scorm-validators.js +69 -0
- package/framework/version.json +2 -2
- package/lib/authoring-api.js +37 -18
- package/lib/build-linter.js +192 -2
- package/lib/build-packaging.js +47 -3
- package/lib/build.js +69 -8
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +5 -3
- package/lib/dev.js +1 -1
- package/lib/headless-browser.js +66 -14
- package/lib/manifest/cmi5-manifest.js +26 -4
- package/lib/manifest/lti-tool-config.js +7 -2
- package/lib/manifest/manifest-factory.d.ts +1 -1
- package/lib/manifest/manifest-factory.js +3 -2
- package/lib/manifest/scorm-12-manifest.js +2 -2
- package/lib/manifest/scorm-2004-manifest.js +3 -28
- package/lib/manifest/scorm-proxy-manifest.js +37 -32
- package/lib/manifest/xml-utils.js +10 -0
- package/lib/narration.js +2 -2
- package/lib/portable-html.js +196 -0
- package/lib/preview-export.js +1 -1
- package/lib/preview-server.js +27 -6
- package/lib/project-utils.js +9 -1
- package/lib/proxy-templates/proxy.html +4 -1
- package/lib/proxy-templates/scorm-bridge.js +82 -10
- package/lib/scaffold.js +9 -4
- package/lib/stub-player/lms-api.js +63 -8
- package/package.json +5 -1
- package/schemas/adlcp_rootv1p2.xsd +110 -0
- package/schemas/coursecode_scorm12_package.xsd +5 -0
- package/schemas/coursecode_scorm2004_package.xsd +8 -0
- package/schemas/imscp_rootv1p1p2.xsd +304 -0
- package/template/course/slides/example-ui-showcase.js +1 -1
- package/template/vite.config.js +50 -22
|
@@ -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
|
|
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
|
|
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}®istration=${registration}`;
|
|
228
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|