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
|
@@ -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:
|
|
120
|
-
supportsInteractions:
|
|
224
|
+
supportsObjectives: this._supportsObjectives,
|
|
225
|
+
supportsInteractions: this._supportsInteractions,
|
|
121
226
|
supportsComments: false, // SCORM 1.2 comments are read-only
|
|
122
|
-
supportsEmergencySave:
|
|
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 =
|
|
191
|
-
if (
|
|
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:
|
|
198
|
-
max:
|
|
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
|
|
269
|
-
this._rawSet(
|
|
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`,
|
|
282
|
-
this._rawSet(`cmi.interactions.${index}.type`,
|
|
389
|
+
this._rawSet(`cmi.interactions.${index}.id`, serializedInteraction.id);
|
|
390
|
+
this._rawSet(`cmi.interactions.${index}.type`, serializedInteraction.type);
|
|
283
391
|
|
|
284
|
-
if (
|
|
285
|
-
this._rawSet(`cmi.interactions.${index}.student_response`,
|
|
392
|
+
if (serializedInteraction.learner_response !== '') {
|
|
393
|
+
this._rawSet(`cmi.interactions.${index}.student_response`, serializedInteraction.learner_response);
|
|
286
394
|
}
|
|
287
|
-
if (
|
|
288
|
-
this._rawSet(`cmi.interactions.${index}.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 (
|
|
302
|
-
|
|
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 (
|
|
312
|
-
|
|
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.
|
|
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 =
|
|
448
|
+
const parsed = decodeScorm12SuspendState(data);
|
|
347
449
|
return this._expandDietState(parsed);
|
|
348
450
|
} catch (error) {
|
|
349
|
-
logger.error('[Scorm12Driver] Failed to
|
|
350
|
-
throw new Error(`
|
|
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.
|
|
462
|
+
const dietData = createScorm12DietState(data, this._cache.bookmark || null);
|
|
361
463
|
|
|
362
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
this._cache.interactionsCount =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
221
|
-
|
|
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:
|
|
229
|
-
min:
|
|
230
|
-
max:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
614
|
-
if (
|
|
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 =
|
|
622
|
-
if (
|
|
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 =
|
|
648
|
+
interactionsCount = parseNonNegativeInteger(this._getValue('cmi.interactions._count') || '0');
|
|
637
649
|
} catch (_e) { /* No interactions stored — normal */ }
|
|
638
|
-
this._cmiCache.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 =
|
|
654
|
-
if (
|
|
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.
|
|
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
|
}
|