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
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { logger } from '../utilities/logger.js';
|
|
14
|
+
import LZString from 'lz-string';
|
|
15
|
+
import {
|
|
16
|
+
mapStatusTo12,
|
|
17
|
+
mapObjectiveStatusTo12,
|
|
18
|
+
convertTimeFormat2004To12,
|
|
19
|
+
convertTimestamp2004To12,
|
|
20
|
+
createScorm12DietState,
|
|
21
|
+
expandScorm12DietState,
|
|
22
|
+
encodeScorm12SuspendState,
|
|
23
|
+
decodeScorm12SuspendState
|
|
24
|
+
} from './scorm-12-driver.js';
|
|
25
|
+
import { serializeInteractionForScorm12 } from '../validation/scorm-validators.js';
|
|
14
26
|
|
|
15
27
|
// Message timeout (ms) — if proxy doesn't respond, something is wrong
|
|
16
28
|
const MESSAGE_TIMEOUT = 5000;
|
|
@@ -25,6 +37,7 @@ export class ProxyDriver {
|
|
|
25
37
|
this._isTerminated = false;
|
|
26
38
|
this._msgId = 0;
|
|
27
39
|
this._pending = new Map(); // id -> { resolve, reject, timeout }
|
|
40
|
+
this._pendingWrites = [];
|
|
28
41
|
|
|
29
42
|
// Origin of the parent proxy frame (set during initialize)
|
|
30
43
|
// Used for postMessage origin validation
|
|
@@ -39,12 +52,19 @@ export class ProxyDriver {
|
|
|
39
52
|
completion: 'unknown',
|
|
40
53
|
success: 'unknown'
|
|
41
54
|
};
|
|
55
|
+
this._scoreCache = null;
|
|
56
|
+
this._objectiveIdToIndex = new Map();
|
|
57
|
+
this._objectivesCount = 0;
|
|
58
|
+
this._interactionsCount = 0;
|
|
59
|
+
this._supportsObjectives = this._baseFormat === 'scorm2004';
|
|
60
|
+
this._supportsInteractions = this._baseFormat === 'scorm2004';
|
|
61
|
+
this._scoreChildren = new Set(['raw']);
|
|
62
|
+
this._handshake = null;
|
|
42
63
|
|
|
43
64
|
this._suspendDataCache = null;
|
|
44
65
|
|
|
45
|
-
// Listen for responses from proxy bridge
|
|
46
66
|
this._handleMessage = this._handleMessage.bind(this);
|
|
47
|
-
|
|
67
|
+
this._isListening = false;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
// =========================================================================
|
|
@@ -67,10 +87,10 @@ export class ProxyDriver {
|
|
|
67
87
|
// Mirror base format capabilities, but note async commit
|
|
68
88
|
const isScorm2004 = this._baseFormat === 'scorm2004';
|
|
69
89
|
return {
|
|
70
|
-
supportsObjectives:
|
|
71
|
-
supportsInteractions:
|
|
90
|
+
supportsObjectives: this._supportsObjectives,
|
|
91
|
+
supportsInteractions: this._supportsInteractions,
|
|
72
92
|
supportsComments: isScorm2004,
|
|
73
|
-
supportsEmergencySave:
|
|
93
|
+
supportsEmergencySave: true,
|
|
74
94
|
maxSuspendDataBytes: isScorm2004 ? 65536 : 4096,
|
|
75
95
|
asyncCommit: true // postMessage is inherently async
|
|
76
96
|
};
|
|
@@ -87,24 +107,22 @@ export class ProxyDriver {
|
|
|
87
107
|
throw new Error('ProxyDriver: Not running in iframe - proxy mode requires parent frame');
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
110
|
+
// Referrer is only a target-origin optimization. Some real LMSs use a
|
|
111
|
+
// no-referrer policy, so identity is established with a nonce handshake
|
|
112
|
+
// bound to window.parent and then locked to the response origin.
|
|
113
|
+
let handshakeTarget = '*';
|
|
92
114
|
try {
|
|
93
115
|
if (document.referrer) {
|
|
94
116
|
const referrerUrl = new URL(document.referrer);
|
|
95
|
-
|
|
96
|
-
logger.debug('ProxyDriver: Parent origin from referrer:', this._parentOrigin);
|
|
117
|
+
handshakeTarget = referrerUrl.origin;
|
|
97
118
|
} else if (window.location.ancestorOrigins?.[0]) {
|
|
98
|
-
|
|
119
|
+
handshakeTarget = new URL(window.location.ancestorOrigins[0]).origin;
|
|
99
120
|
}
|
|
100
|
-
} catch {
|
|
101
|
-
this._parentOrigin = null;
|
|
102
|
-
}
|
|
103
|
-
if (!this._parentOrigin) {
|
|
104
|
-
throw new Error('ProxyDriver: Cannot determine parent origin; refusing insecure wildcard postMessage transport');
|
|
105
|
-
}
|
|
121
|
+
} catch { /* Handshake safely discovers the parent origin. */ }
|
|
106
122
|
|
|
123
|
+
this._startListening();
|
|
107
124
|
try {
|
|
125
|
+
await this._establishHandshake(handshakeTarget);
|
|
108
126
|
const result = await this._sendMessage('Initialize');
|
|
109
127
|
if (result === true || result === 'true') {
|
|
110
128
|
this._isConnected = true;
|
|
@@ -117,6 +135,8 @@ export class ProxyDriver {
|
|
|
117
135
|
}
|
|
118
136
|
throw new Error('ProxyDriver: Initialize returned false');
|
|
119
137
|
} catch (error) {
|
|
138
|
+
this._isConnected = false;
|
|
139
|
+
this._stopListening('initialization failed');
|
|
120
140
|
logger.error('ProxyDriver: Initialize failed', error);
|
|
121
141
|
throw error;
|
|
122
142
|
}
|
|
@@ -134,16 +154,18 @@ export class ProxyDriver {
|
|
|
134
154
|
}
|
|
135
155
|
|
|
136
156
|
try {
|
|
157
|
+
await this._flushPendingWrites();
|
|
137
158
|
const result = await this._sendMessage('Terminate');
|
|
159
|
+
if (result !== true && result !== 'true') {
|
|
160
|
+
throw new Error('ProxyDriver: LMS Terminate returned false');
|
|
161
|
+
}
|
|
138
162
|
this._isTerminated = true;
|
|
139
163
|
this._isConnected = false;
|
|
140
|
-
|
|
164
|
+
this._stopListening('session terminated');
|
|
141
165
|
logger.info('ProxyDriver: Terminated');
|
|
142
|
-
return
|
|
166
|
+
return true;
|
|
143
167
|
} catch (error) {
|
|
144
168
|
logger.error('ProxyDriver: Terminate failed', error);
|
|
145
|
-
this._isTerminated = true;
|
|
146
|
-
this._isConnected = false;
|
|
147
169
|
throw error;
|
|
148
170
|
}
|
|
149
171
|
}
|
|
@@ -151,8 +173,12 @@ export class ProxyDriver {
|
|
|
151
173
|
async commit() {
|
|
152
174
|
this._ensureConnected();
|
|
153
175
|
try {
|
|
176
|
+
await this._flushPendingWrites();
|
|
154
177
|
const result = await this._sendMessage('Commit');
|
|
155
|
-
|
|
178
|
+
if (result !== true && result !== 'true') {
|
|
179
|
+
throw new Error('ProxyDriver: LMS Commit returned false');
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
156
182
|
} catch (error) {
|
|
157
183
|
logger.error('ProxyDriver: Commit failed', error);
|
|
158
184
|
throw error;
|
|
@@ -161,12 +187,33 @@ export class ProxyDriver {
|
|
|
161
187
|
|
|
162
188
|
ping() {
|
|
163
189
|
if (this._isConnected && !this._isTerminated) {
|
|
164
|
-
this.
|
|
190
|
+
const key = this._baseFormat === 'scorm1.2' ? 'cmi.core.student_id' : 'cmi.learner_id';
|
|
191
|
+
this._sendMessage('GetValue', key).catch(() => {
|
|
165
192
|
// Ignore ping errors
|
|
166
193
|
});
|
|
167
194
|
}
|
|
168
195
|
}
|
|
169
196
|
|
|
197
|
+
emergencySave() {
|
|
198
|
+
if (!this._isConnected || this._isTerminated || !this._parentOrigin) return;
|
|
199
|
+
try {
|
|
200
|
+
window.parent.postMessage({
|
|
201
|
+
type: 'scorm-proxy-request',
|
|
202
|
+
id: null,
|
|
203
|
+
method: 'EmergencySave',
|
|
204
|
+
args: []
|
|
205
|
+
}, this._parentOrigin);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger.error('ProxyDriver: Emergency save message failed', {
|
|
208
|
+
domain: 'scorm-proxy',
|
|
209
|
+
operation: 'emergencySave',
|
|
210
|
+
format: this.getFormat(),
|
|
211
|
+
error: error.message,
|
|
212
|
+
stack: error.stack
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
170
217
|
// =========================================================================
|
|
171
218
|
// Semantic Reads (served from pre-fetched cache)
|
|
172
219
|
// =========================================================================
|
|
@@ -187,6 +234,10 @@ export class ProxyDriver {
|
|
|
187
234
|
return this._cache.success;
|
|
188
235
|
}
|
|
189
236
|
|
|
237
|
+
getScore() {
|
|
238
|
+
return this._scoreCache ? { ...this._scoreCache } : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
190
241
|
getLearnerInfo() {
|
|
191
242
|
return {
|
|
192
243
|
id: this._cache.learnerId,
|
|
@@ -207,18 +258,34 @@ export class ProxyDriver {
|
|
|
207
258
|
reportScore({ raw, scaled, min, max }) {
|
|
208
259
|
const prefix = this._baseFormat === 'scorm1.2' ? 'cmi.core.score' : 'cmi.score';
|
|
209
260
|
if (raw !== undefined) this._sendSetValue(`${prefix}.raw`, String(raw));
|
|
210
|
-
if (min !== undefined
|
|
211
|
-
|
|
261
|
+
if (min !== undefined && (!this._isScorm12() || this._scoreChildren.has('min'))) {
|
|
262
|
+
this._sendSetValue(`${prefix}.min`, String(min));
|
|
263
|
+
}
|
|
264
|
+
if (max !== undefined && (!this._isScorm12() || this._scoreChildren.has('max'))) {
|
|
265
|
+
this._sendSetValue(`${prefix}.max`, String(max));
|
|
266
|
+
}
|
|
212
267
|
if (scaled !== undefined && this._baseFormat === 'scorm2004') {
|
|
213
268
|
this._sendSetValue('cmi.score.scaled', String(scaled));
|
|
214
269
|
}
|
|
270
|
+
const resolvedRaw = raw ?? (scaled !== undefined ? scaled * 100 : null);
|
|
271
|
+
const resolvedScaled = scaled ?? (raw !== undefined ? raw / 100 : null);
|
|
272
|
+
if (resolvedRaw !== null || resolvedScaled !== null) {
|
|
273
|
+
this._scoreCache = {
|
|
274
|
+
raw: resolvedRaw,
|
|
275
|
+
scaled: resolvedScaled,
|
|
276
|
+
min: min ?? 0,
|
|
277
|
+
max: max ?? 100
|
|
278
|
+
};
|
|
279
|
+
}
|
|
215
280
|
}
|
|
216
281
|
|
|
217
282
|
reportCompletion(status) {
|
|
218
283
|
this._cache.completion = status;
|
|
219
284
|
if (this._baseFormat === 'scorm1.2') {
|
|
220
|
-
|
|
221
|
-
|
|
285
|
+
this._sendSetValue(
|
|
286
|
+
'cmi.core.lesson_status',
|
|
287
|
+
mapStatusTo12(this._cache.completion, this._cache.success)
|
|
288
|
+
);
|
|
222
289
|
} else {
|
|
223
290
|
this._sendSetValue('cmi.completion_status', status);
|
|
224
291
|
}
|
|
@@ -227,7 +294,10 @@ export class ProxyDriver {
|
|
|
227
294
|
reportSuccess(status) {
|
|
228
295
|
this._cache.success = status;
|
|
229
296
|
if (this._baseFormat === 'scorm1.2') {
|
|
230
|
-
this._sendSetValue(
|
|
297
|
+
this._sendSetValue(
|
|
298
|
+
'cmi.core.lesson_status',
|
|
299
|
+
mapStatusTo12(this._cache.completion, this._cache.success)
|
|
300
|
+
);
|
|
231
301
|
} else {
|
|
232
302
|
this._sendSetValue('cmi.success_status', status);
|
|
233
303
|
}
|
|
@@ -241,8 +311,7 @@ export class ProxyDriver {
|
|
|
241
311
|
|
|
242
312
|
reportSessionTime(duration) {
|
|
243
313
|
if (this._baseFormat === 'scorm1.2') {
|
|
244
|
-
|
|
245
|
-
this._sendSetValue('cmi.session_time', duration);
|
|
314
|
+
this._sendSetValue('cmi.core.session_time', convertTimeFormat2004To12(duration));
|
|
246
315
|
} else {
|
|
247
316
|
this._sendSetValue('cmi.session_time', duration);
|
|
248
317
|
}
|
|
@@ -250,13 +319,74 @@ export class ProxyDriver {
|
|
|
250
319
|
|
|
251
320
|
reportObjective(objective) {
|
|
252
321
|
if (!objective || !objective.id) return;
|
|
253
|
-
|
|
254
|
-
|
|
322
|
+
if (!this._supportsObjectives) return;
|
|
323
|
+
const index = this._getOrCreateObjectiveIndex(objective.id);
|
|
324
|
+
|
|
325
|
+
if (this._baseFormat === 'scorm1.2') {
|
|
326
|
+
if (objective.score !== null && objective.score !== undefined) {
|
|
327
|
+
this._sendSetValue(`cmi.objectives.${index}.score.raw`, String(objective.score));
|
|
328
|
+
this._sendSetValue(`cmi.objectives.${index}.score.min`, '0');
|
|
329
|
+
this._sendSetValue(`cmi.objectives.${index}.score.max`, '100');
|
|
330
|
+
}
|
|
331
|
+
if (objective.completion_status !== undefined || objective.success_status !== undefined) {
|
|
332
|
+
this._sendSetValue(
|
|
333
|
+
`cmi.objectives.${index}.status`,
|
|
334
|
+
mapObjectiveStatusTo12(objective.completion_status, objective.success_status)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (objective.success_status) this._sendSetValue(`cmi.objectives.${index}.success_status`, objective.success_status);
|
|
341
|
+
if (objective.completion_status) this._sendSetValue(`cmi.objectives.${index}.completion_status`, objective.completion_status);
|
|
342
|
+
if (objective.score !== null && objective.score !== undefined) {
|
|
343
|
+
this._sendSetValue(`cmi.objectives.${index}.score.raw`, String(objective.score));
|
|
344
|
+
this._sendSetValue(`cmi.objectives.${index}.score.scaled`, String(objective.score / 100));
|
|
345
|
+
this._sendSetValue(`cmi.objectives.${index}.score.min`, '0');
|
|
346
|
+
this._sendSetValue(`cmi.objectives.${index}.score.max`, '100');
|
|
347
|
+
}
|
|
348
|
+
if (objective.progress_measure !== null && objective.progress_measure !== undefined) {
|
|
349
|
+
this._sendSetValue(`cmi.objectives.${index}.progress_measure`, String(objective.progress_measure));
|
|
350
|
+
}
|
|
351
|
+
if (objective.description) this._sendSetValue(`cmi.objectives.${index}.description`, objective.description);
|
|
255
352
|
}
|
|
256
353
|
|
|
257
354
|
reportInteraction(interaction) {
|
|
258
|
-
if (!interaction || !interaction.id) return;
|
|
259
|
-
|
|
355
|
+
if (!interaction || !interaction.id || !interaction.type) return;
|
|
356
|
+
if (!this._supportsInteractions) {
|
|
357
|
+
return { ...interaction, _index: null, nativeCmiSkipped: true };
|
|
358
|
+
}
|
|
359
|
+
const index = this._interactionsCount++;
|
|
360
|
+
const is12 = this._baseFormat === 'scorm1.2';
|
|
361
|
+
const reported = is12 ? serializeInteractionForScorm12(interaction) : interaction;
|
|
362
|
+
const prefix = `cmi.interactions.${index}`;
|
|
363
|
+
|
|
364
|
+
this._sendSetValue(`${prefix}.id`, reported.id);
|
|
365
|
+
this._sendSetValue(`${prefix}.type`, reported.type);
|
|
366
|
+
if (reported.learner_response !== undefined && reported.learner_response !== null && reported.learner_response !== '') {
|
|
367
|
+
this._sendSetValue(`${prefix}.${is12 ? 'student_response' : 'learner_response'}`, reported.learner_response);
|
|
368
|
+
}
|
|
369
|
+
if (reported.result) this._sendSetValue(`${prefix}.result`, reported.result);
|
|
370
|
+
if (interaction.timestamp) {
|
|
371
|
+
this._sendSetValue(
|
|
372
|
+
`${prefix}.${is12 ? 'time' : 'timestamp'}`,
|
|
373
|
+
is12 ? convertTimestamp2004To12(interaction.timestamp) : interaction.timestamp
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (!is12 && interaction.description) this._sendSetValue(`${prefix}.description`, interaction.description);
|
|
377
|
+
if (interaction.weighting !== undefined && interaction.weighting !== null) {
|
|
378
|
+
this._sendSetValue(`${prefix}.weighting`, String(interaction.weighting));
|
|
379
|
+
}
|
|
380
|
+
if (interaction.latency) {
|
|
381
|
+
this._sendSetValue(`${prefix}.latency`, is12 ? convertTimeFormat2004To12(interaction.latency) : interaction.latency);
|
|
382
|
+
}
|
|
383
|
+
reported.correct_responses?.forEach((item, patternIndex) => {
|
|
384
|
+
const pattern = typeof item === 'object' && item !== null && 'pattern' in item ? item.pattern : item;
|
|
385
|
+
this._sendSetValue(`${prefix}.correct_responses.${patternIndex}.pattern`, pattern);
|
|
386
|
+
});
|
|
387
|
+
reported.objectives?.forEach((objectiveId, objectiveIndex) => {
|
|
388
|
+
this._sendSetValue(`${prefix}.objectives.${objectiveIndex}.id`, objectiveId);
|
|
389
|
+
});
|
|
260
390
|
}
|
|
261
391
|
|
|
262
392
|
setExitMode(mode) {
|
|
@@ -275,7 +405,16 @@ export class ProxyDriver {
|
|
|
275
405
|
|
|
276
406
|
setSuspendData(data) {
|
|
277
407
|
this._ensureConnected();
|
|
278
|
-
const
|
|
408
|
+
const state = this._baseFormat === 'scorm1.2'
|
|
409
|
+
? createScorm12DietState(data, this._cache.bookmark || null)
|
|
410
|
+
: data;
|
|
411
|
+
const value = this._isScorm12()
|
|
412
|
+
? encodeScorm12SuspendState(state)
|
|
413
|
+
: LZString.compressToUTF16(JSON.stringify(state));
|
|
414
|
+
const limit = this._baseFormat === 'scorm1.2' ? 4096 : 64000;
|
|
415
|
+
if (value.length > limit) {
|
|
416
|
+
throw new Error(`${this.getFormat()} suspend_data exceeds the ${limit}-character limit (${value.length})`);
|
|
417
|
+
}
|
|
279
418
|
this._sendSetValue('cmi.suspend_data', value);
|
|
280
419
|
this._suspendDataCache = data;
|
|
281
420
|
return true;
|
|
@@ -299,7 +438,14 @@ export class ProxyDriver {
|
|
|
299
438
|
learnerId: 'cmi.core.student_id',
|
|
300
439
|
learnerName: 'cmi.core.student_name',
|
|
301
440
|
status: 'cmi.core.lesson_status',
|
|
302
|
-
suspendData: 'cmi.suspend_data'
|
|
441
|
+
suspendData: 'cmi.suspend_data',
|
|
442
|
+
scoreRaw: 'cmi.core.score.raw',
|
|
443
|
+
scoreMin: 'cmi.core.score.min',
|
|
444
|
+
scoreMax: 'cmi.core.score.max',
|
|
445
|
+
objectivesCount: 'cmi.objectives._count',
|
|
446
|
+
interactionsCount: 'cmi.interactions._count',
|
|
447
|
+
children: 'cmi._children',
|
|
448
|
+
scoreChildren: 'cmi.core.score._children'
|
|
303
449
|
} : {
|
|
304
450
|
entry: 'cmi.entry',
|
|
305
451
|
bookmark: 'cmi.location',
|
|
@@ -307,15 +453,27 @@ export class ProxyDriver {
|
|
|
307
453
|
learnerName: 'cmi.learner_name',
|
|
308
454
|
completion: 'cmi.completion_status',
|
|
309
455
|
success: 'cmi.success_status',
|
|
310
|
-
suspendData: 'cmi.suspend_data'
|
|
456
|
+
suspendData: 'cmi.suspend_data',
|
|
457
|
+
scoreScaled: 'cmi.score.scaled',
|
|
458
|
+
scoreRaw: 'cmi.score.raw',
|
|
459
|
+
scoreMin: 'cmi.score.min',
|
|
460
|
+
scoreMax: 'cmi.score.max',
|
|
461
|
+
objectivesCount: 'cmi.objectives._count',
|
|
462
|
+
interactionsCount: 'cmi.interactions._count'
|
|
311
463
|
};
|
|
312
464
|
|
|
313
465
|
// Fire all reads in parallel
|
|
314
466
|
const results = {};
|
|
467
|
+
const requiredFields = new Set(is12
|
|
468
|
+
? ['entry', 'bookmark', 'learnerId', 'learnerName', 'status', 'suspendData']
|
|
469
|
+
: ['entry', 'bookmark', 'learnerId', 'learnerName', 'completion', 'success', 'suspendData']);
|
|
315
470
|
const promises = Object.entries(keys).map(async ([fieldName, cmiKey]) => {
|
|
316
471
|
try {
|
|
317
472
|
results[fieldName] = await this._sendMessage('GetValue', cmiKey);
|
|
318
|
-
} catch {
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (requiredFields.has(fieldName)) {
|
|
475
|
+
throw new Error(`ProxyDriver: Cannot read required LMS value ${cmiKey}: ${error.message}`);
|
|
476
|
+
}
|
|
319
477
|
results[fieldName] = '';
|
|
320
478
|
}
|
|
321
479
|
});
|
|
@@ -344,18 +502,55 @@ export class ProxyDriver {
|
|
|
344
502
|
this._cache.success = 'unknown';
|
|
345
503
|
}
|
|
346
504
|
this._cache.entry = results.entry || '';
|
|
505
|
+
const supported = new Set(String(results.children || '').split(',').map(value => value.trim()));
|
|
506
|
+
this._supportsObjectives = supported.has('objectives');
|
|
507
|
+
this._supportsInteractions = supported.has('interactions');
|
|
508
|
+
this._scoreChildren = new Set([
|
|
509
|
+
'raw',
|
|
510
|
+
...String(results.scoreChildren || '').split(',').map(value => value.trim()).filter(Boolean)
|
|
511
|
+
]);
|
|
347
512
|
} else {
|
|
348
513
|
this._cache.completion = results.completion || 'unknown';
|
|
349
514
|
this._cache.success = results.success || 'unknown';
|
|
350
515
|
this._cache.entry = results.entry || '';
|
|
351
516
|
}
|
|
352
517
|
|
|
518
|
+
const parseOptionalNumber = value => {
|
|
519
|
+
if (value === '' || value === null || value === undefined) return NaN;
|
|
520
|
+
return Number(value);
|
|
521
|
+
};
|
|
522
|
+
const raw = parseOptionalNumber(results.scoreRaw);
|
|
523
|
+
const scaled = is12 ? raw / 100 : parseOptionalNumber(results.scoreScaled);
|
|
524
|
+
if (Number.isFinite(raw) || Number.isFinite(scaled)) {
|
|
525
|
+
this._scoreCache = {
|
|
526
|
+
raw: Number.isFinite(raw) ? raw : scaled * 100,
|
|
527
|
+
scaled: Number.isFinite(scaled) ? scaled : raw / 100,
|
|
528
|
+
min: Number.isFinite(parseOptionalNumber(results.scoreMin)) ? Number(results.scoreMin) : 0,
|
|
529
|
+
max: Number.isFinite(parseOptionalNumber(results.scoreMax)) ? Number(results.scoreMax) : 100
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this._objectivesCount = this._supportsObjectives ? this._parseCount(results.objectivesCount) : 0;
|
|
534
|
+
this._interactionsCount = this._supportsInteractions ? this._parseCount(results.interactionsCount) : 0;
|
|
535
|
+
this._objectiveIdToIndex.clear();
|
|
536
|
+
for (let i = 0; i < this._objectivesCount; i++) {
|
|
537
|
+
try {
|
|
538
|
+
const id = await this._sendMessage('GetValue', `cmi.objectives.${i}.id`);
|
|
539
|
+
if (id) this._objectiveIdToIndex.set(id, i);
|
|
540
|
+
} catch {
|
|
541
|
+
// Keep the LMS count authoritative even if one row cannot be read.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
353
545
|
// Parse suspend_data
|
|
354
546
|
if (results.suspendData) {
|
|
355
547
|
try {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
548
|
+
const parsed = is12
|
|
549
|
+
? decodeScorm12SuspendState(results.suspendData)
|
|
550
|
+
: JSON.parse(LZString.decompressFromUTF16(results.suspendData));
|
|
551
|
+
this._suspendDataCache = is12 ? expandScorm12DietState(parsed) : parsed;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
throw new Error(`ProxyDriver: Resume state cannot be safely restored: ${error.message}`);
|
|
359
554
|
}
|
|
360
555
|
}
|
|
361
556
|
|
|
@@ -370,6 +565,30 @@ export class ProxyDriver {
|
|
|
370
565
|
// Private: postMessage Transport
|
|
371
566
|
// =========================================================================
|
|
372
567
|
|
|
568
|
+
_startListening() {
|
|
569
|
+
if (this._isListening) return;
|
|
570
|
+
window.addEventListener('message', this._handleMessage);
|
|
571
|
+
this._isListening = true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
_stopListening(reason) {
|
|
575
|
+
if (this._isListening) {
|
|
576
|
+
window.removeEventListener('message', this._handleMessage);
|
|
577
|
+
this._isListening = false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const pending of this._pending.values()) {
|
|
581
|
+
clearTimeout(pending.timeout);
|
|
582
|
+
pending.reject(new Error(`ProxyDriver: Request cancelled because ${reason}`));
|
|
583
|
+
}
|
|
584
|
+
this._pending.clear();
|
|
585
|
+
if (this._handshake) {
|
|
586
|
+
clearTimeout(this._handshake.timeout);
|
|
587
|
+
this._handshake.reject(new Error(`ProxyDriver: Handshake cancelled because ${reason}`));
|
|
588
|
+
this._handshake = null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
373
592
|
_ensureConnected() {
|
|
374
593
|
if (!this._isConnected) {
|
|
375
594
|
throw new Error('ProxyDriver: Not connected');
|
|
@@ -379,13 +598,70 @@ export class ProxyDriver {
|
|
|
379
598
|
}
|
|
380
599
|
}
|
|
381
600
|
|
|
601
|
+
_parseCount(value) {
|
|
602
|
+
const parsed = Number(value);
|
|
603
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
_isScorm12() {
|
|
607
|
+
return this._baseFormat === 'scorm1.2';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
_establishHandshake(targetOrigin) {
|
|
611
|
+
return new Promise((resolve, reject) => {
|
|
612
|
+
const nonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
613
|
+
const timeout = setTimeout(() => {
|
|
614
|
+
this._handshake = null;
|
|
615
|
+
reject(new Error('ProxyDriver: Timeout waiting for secure proxy handshake'));
|
|
616
|
+
}, MESSAGE_TIMEOUT);
|
|
617
|
+
|
|
618
|
+
this._handshake = { nonce, resolve, reject, timeout };
|
|
619
|
+
try {
|
|
620
|
+
window.parent.postMessage({
|
|
621
|
+
type: 'scorm-proxy-handshake',
|
|
622
|
+
nonce,
|
|
623
|
+
baseFormat: this._baseFormat
|
|
624
|
+
}, targetOrigin);
|
|
625
|
+
} catch (error) {
|
|
626
|
+
clearTimeout(timeout);
|
|
627
|
+
this._handshake = null;
|
|
628
|
+
reject(error);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_getOrCreateObjectiveIndex(objectiveId) {
|
|
634
|
+
if (this._objectiveIdToIndex.has(objectiveId)) {
|
|
635
|
+
return this._objectiveIdToIndex.get(objectiveId);
|
|
636
|
+
}
|
|
637
|
+
const index = this._objectivesCount++;
|
|
638
|
+
this._objectiveIdToIndex.set(objectiveId, index);
|
|
639
|
+
this._sendSetValue(`cmi.objectives.${index}.id`, objectiveId);
|
|
640
|
+
return index;
|
|
641
|
+
}
|
|
642
|
+
|
|
382
643
|
/**
|
|
383
644
|
* Fire-and-forget setValue to proxy bridge.
|
|
384
645
|
*/
|
|
385
646
|
_sendSetValue(key, value) {
|
|
386
|
-
this._sendMessage('SetValue', key, String(value))
|
|
387
|
-
|
|
388
|
-
|
|
647
|
+
const write = this._sendMessage('SetValue', key, String(value))
|
|
648
|
+
.then(result => {
|
|
649
|
+
if (result !== true && result !== 'true') {
|
|
650
|
+
throw new Error(`ProxyDriver: LMS rejected SetValue('${key}')`);
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
})
|
|
654
|
+
.catch(error => ({ error }));
|
|
655
|
+
this._pendingWrites.push(write);
|
|
656
|
+
return write;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async _flushPendingWrites() {
|
|
660
|
+
const writes = this._pendingWrites.splice(0);
|
|
661
|
+
if (writes.length === 0) return;
|
|
662
|
+
const results = await Promise.all(writes);
|
|
663
|
+
const failure = results.find(result => result?.error);
|
|
664
|
+
if (failure) throw failure.error;
|
|
389
665
|
}
|
|
390
666
|
|
|
391
667
|
/**
|
|
@@ -402,12 +678,18 @@ export class ProxyDriver {
|
|
|
402
678
|
|
|
403
679
|
this._pending.set(id, { resolve, reject, timeout });
|
|
404
680
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
681
|
+
try {
|
|
682
|
+
window.parent.postMessage({
|
|
683
|
+
type: 'scorm-proxy-request',
|
|
684
|
+
id,
|
|
685
|
+
method,
|
|
686
|
+
args
|
|
687
|
+
}, this._parentOrigin);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
clearTimeout(timeout);
|
|
690
|
+
this._pending.delete(id);
|
|
691
|
+
reject(error);
|
|
692
|
+
}
|
|
411
693
|
});
|
|
412
694
|
}
|
|
413
695
|
|
|
@@ -417,6 +699,22 @@ export class ProxyDriver {
|
|
|
417
699
|
_handleMessage(event) {
|
|
418
700
|
const { data } = event;
|
|
419
701
|
|
|
702
|
+
if (data?.type === 'scorm-proxy-handshake-response') {
|
|
703
|
+
if (event.source !== window.parent || !this._handshake || data.nonce !== this._handshake.nonce) return;
|
|
704
|
+
const handshake = this._handshake;
|
|
705
|
+
clearTimeout(handshake.timeout);
|
|
706
|
+
this._handshake = null;
|
|
707
|
+
if (data.baseFormat !== this._baseFormat) {
|
|
708
|
+
handshake.reject(new Error(
|
|
709
|
+
`ProxyDriver: Proxy package format ${data.baseFormat} does not match course format ${this._baseFormat}`
|
|
710
|
+
));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
this._parentOrigin = event.origin;
|
|
714
|
+
handshake.resolve(true);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
420
718
|
if (!data || data.type !== 'scorm-proxy-response') {
|
|
421
719
|
return;
|
|
422
720
|
}
|