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
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
import { eventBus } from '../core/event-bus.js';
|
|
19
19
|
import stateManager from '../state/index.js';
|
|
20
20
|
import { logger } from '../utilities/logger.js';
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_MEDIA_COMPLETION_THRESHOLD,
|
|
23
|
+
normalizeCompletionThreshold
|
|
24
|
+
} from '../utilities/media-utils.js';
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* @typedef {Object} VideoState
|
|
@@ -44,9 +48,6 @@ import { logger } from '../utilities/logger.js';
|
|
|
44
48
|
* @property {number} [completionThreshold=0.95] - Percentage (0-1) required for completion
|
|
45
49
|
*/
|
|
46
50
|
|
|
47
|
-
/** Default completion threshold (95%) */
|
|
48
|
-
const DEFAULT_COMPLETION_THRESHOLD = 0.95;
|
|
49
|
-
|
|
50
51
|
class VideoManager {
|
|
51
52
|
constructor() {
|
|
52
53
|
/** @type {boolean} */
|
|
@@ -63,7 +64,7 @@ class VideoManager {
|
|
|
63
64
|
duration: 0,
|
|
64
65
|
volume: 1,
|
|
65
66
|
required: false,
|
|
66
|
-
completionThreshold:
|
|
67
|
+
completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
|
|
67
68
|
isCompleted: false
|
|
68
69
|
};
|
|
69
70
|
|
|
@@ -78,6 +79,9 @@ class VideoManager {
|
|
|
78
79
|
|
|
79
80
|
/** @type {number|null} */
|
|
80
81
|
this.updateInterval = null;
|
|
82
|
+
|
|
83
|
+
/** @type {HTMLVideoElement|null} */
|
|
84
|
+
this.activeVideo = null;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
@@ -116,41 +120,78 @@ class VideoManager {
|
|
|
116
120
|
throw new Error('VideoManager.attachVideo: contextId is required');
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
// Save position of current context before switching
|
|
120
|
-
if (this.state.contextId && this.state.contextId !== contextId) {
|
|
121
|
-
this._savePosition();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Update state
|
|
125
|
-
this.state.currentSrc = config.src;
|
|
126
|
-
this.state.contextId = contextId;
|
|
127
|
-
this.state.contextType = config.contextType || 'standalone';
|
|
128
|
-
this.state.duration = 0;
|
|
129
|
-
this.state.isPlaying = false;
|
|
130
|
-
this.state.position = 0;
|
|
131
|
-
this.state.required = config.required || false;
|
|
132
|
-
this.state.completionThreshold = config.completionThreshold ?? DEFAULT_COMPLETION_THRESHOLD;
|
|
133
|
-
this.maxPositionReached = 0;
|
|
134
|
-
|
|
135
|
-
// Check if already completed (from previous session)
|
|
136
|
-
this.state.isCompleted = this._isContextCompleted(contextId);
|
|
137
|
-
|
|
138
|
-
// Check for saved position
|
|
139
123
|
const savedPosition = this._getSavedPosition(contextId);
|
|
140
124
|
|
|
125
|
+
video._videoManagerState = {
|
|
126
|
+
currentSrc: config.src,
|
|
127
|
+
contextId,
|
|
128
|
+
contextType: config.contextType || 'standalone',
|
|
129
|
+
duration: 0,
|
|
130
|
+
position: 0,
|
|
131
|
+
required: config.required || false,
|
|
132
|
+
completionThreshold: normalizeCompletionThreshold(config.completionThreshold),
|
|
133
|
+
isCompleted: this._isContextCompleted(contextId),
|
|
134
|
+
maxPositionReached: savedPosition
|
|
135
|
+
};
|
|
136
|
+
|
|
141
137
|
// Set up event listeners
|
|
142
138
|
this._setupVideoListeners(video, contextId, savedPosition);
|
|
143
139
|
|
|
140
|
+
if (!this.activeVideo) {
|
|
141
|
+
this._activateVideo(video);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
144
|
// Emit loadStart event
|
|
145
145
|
eventBus.emit('video:loadStart', {
|
|
146
146
|
contextId,
|
|
147
|
-
contextType:
|
|
147
|
+
contextType: video._videoManagerState.contextType,
|
|
148
148
|
src: config.src
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
logger.debug(`[VideoManager] Attached video: ${contextId}`);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Activates a rendered video, pausing and saving the previous context so
|
|
156
|
+
* playback and completion events cannot be attributed to another player.
|
|
157
|
+
* @private
|
|
158
|
+
* @param {HTMLVideoElement} video
|
|
159
|
+
*/
|
|
160
|
+
_activateVideo(video) {
|
|
161
|
+
const localState = video?._videoManagerState;
|
|
162
|
+
if (!localState || this.activeVideo === video) return;
|
|
163
|
+
|
|
164
|
+
const previousVideo = this.activeVideo;
|
|
165
|
+
if (previousVideo) {
|
|
166
|
+
if (Number.isFinite(previousVideo.currentTime)) {
|
|
167
|
+
this.state.position = previousVideo.currentTime;
|
|
168
|
+
}
|
|
169
|
+
this._savePosition();
|
|
170
|
+
if (!previousVideo.paused) {
|
|
171
|
+
previousVideo.pause();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this._stopPositionUpdates();
|
|
176
|
+
this.activeVideo = video;
|
|
177
|
+
this.maxPositionReached = localState.maxPositionReached;
|
|
178
|
+
this.state = {
|
|
179
|
+
currentSrc: localState.currentSrc,
|
|
180
|
+
contextId: localState.contextId,
|
|
181
|
+
contextType: localState.contextType,
|
|
182
|
+
position: video.currentTime || localState.position || 0,
|
|
183
|
+
isPlaying: !video.paused,
|
|
184
|
+
isMuted: video.muted,
|
|
185
|
+
duration: Number.isFinite(video.duration) && video.duration > 0
|
|
186
|
+
? video.duration
|
|
187
|
+
: localState.duration,
|
|
188
|
+
volume: video.volume,
|
|
189
|
+
required: localState.required,
|
|
190
|
+
completionThreshold: localState.completionThreshold,
|
|
191
|
+
isCompleted: localState.isCompleted
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
154
195
|
/**
|
|
155
196
|
* Sets up event listeners on a video element.
|
|
156
197
|
* @private
|
|
@@ -164,50 +205,66 @@ class VideoManager {
|
|
|
164
205
|
video._videoManagerListeners = {};
|
|
165
206
|
|
|
166
207
|
const listeners = video._videoManagerListeners;
|
|
208
|
+
const localState = video._videoManagerState;
|
|
167
209
|
|
|
168
210
|
listeners.loadedmetadata = () => {
|
|
169
|
-
if (isFinite(video.duration) && video.duration > 0) {
|
|
170
|
-
|
|
211
|
+
if (Number.isFinite(video.duration) && video.duration > 0) {
|
|
212
|
+
localState.duration = video.duration;
|
|
171
213
|
}
|
|
172
214
|
|
|
173
215
|
// Restore position if we have one saved
|
|
174
216
|
if (savedPosition > 0 && savedPosition < video.duration) {
|
|
175
217
|
video.currentTime = savedPosition;
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
localState.position = savedPosition;
|
|
219
|
+
localState.maxPositionReached = savedPosition;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (this.activeVideo === video) {
|
|
223
|
+
this.state.duration = localState.duration;
|
|
224
|
+
this.state.position = localState.position;
|
|
225
|
+
this.maxPositionReached = localState.maxPositionReached;
|
|
178
226
|
}
|
|
179
227
|
|
|
180
228
|
eventBus.emit('video:loaded', {
|
|
181
|
-
src:
|
|
182
|
-
duration:
|
|
229
|
+
src: localState.currentSrc,
|
|
230
|
+
duration: localState.duration,
|
|
183
231
|
contextId,
|
|
184
|
-
contextType:
|
|
232
|
+
contextType: localState.contextType
|
|
185
233
|
});
|
|
186
234
|
};
|
|
187
235
|
|
|
188
236
|
listeners.play = () => {
|
|
237
|
+
this._activateVideo(video);
|
|
189
238
|
this.state.isPlaying = true;
|
|
190
239
|
this._startPositionUpdates(video);
|
|
191
240
|
eventBus.emit('video:play', {
|
|
192
241
|
contextId,
|
|
193
|
-
contextType:
|
|
242
|
+
contextType: localState.contextType
|
|
194
243
|
});
|
|
195
244
|
};
|
|
196
245
|
|
|
197
246
|
listeners.pause = () => {
|
|
198
|
-
|
|
199
|
-
this.
|
|
200
|
-
|
|
247
|
+
localState.position = video.currentTime;
|
|
248
|
+
if (this.activeVideo === video) {
|
|
249
|
+
this.state.position = video.currentTime;
|
|
250
|
+
this.state.isPlaying = false;
|
|
251
|
+
this._stopPositionUpdates();
|
|
252
|
+
this._savePosition();
|
|
253
|
+
}
|
|
201
254
|
eventBus.emit('video:pause', {
|
|
202
255
|
contextId,
|
|
203
|
-
contextType:
|
|
204
|
-
position:
|
|
256
|
+
contextType: localState.contextType,
|
|
257
|
+
position: localState.position
|
|
205
258
|
});
|
|
206
259
|
};
|
|
207
260
|
|
|
208
261
|
listeners.ended = () => {
|
|
262
|
+
this._activateVideo(video);
|
|
263
|
+
localState.position = video.duration;
|
|
264
|
+
localState.maxPositionReached = video.duration;
|
|
209
265
|
this.state.isPlaying = false;
|
|
210
266
|
this.state.position = video.duration;
|
|
267
|
+
this.maxPositionReached = video.duration;
|
|
211
268
|
this._stopPositionUpdates();
|
|
212
269
|
|
|
213
270
|
// Mark as completed when video ends
|
|
@@ -217,31 +274,38 @@ class VideoManager {
|
|
|
217
274
|
};
|
|
218
275
|
|
|
219
276
|
listeners.timeupdate = () => {
|
|
220
|
-
|
|
277
|
+
localState.position = video.currentTime;
|
|
221
278
|
|
|
222
279
|
// Track max position for completion calculation
|
|
223
|
-
if (video.currentTime >
|
|
224
|
-
|
|
280
|
+
if (video.currentTime > localState.maxPositionReached) {
|
|
281
|
+
localState.maxPositionReached = video.currentTime;
|
|
225
282
|
}
|
|
226
283
|
|
|
227
|
-
|
|
228
|
-
|
|
284
|
+
if (this.activeVideo === video) {
|
|
285
|
+
this.state.position = video.currentTime;
|
|
286
|
+
this.maxPositionReached = localState.maxPositionReached;
|
|
287
|
+
this._checkAndMarkCompleted();
|
|
288
|
+
}
|
|
229
289
|
};
|
|
230
290
|
|
|
231
291
|
listeners.volumechange = () => {
|
|
232
|
-
this.
|
|
233
|
-
|
|
234
|
-
|
|
292
|
+
if (this.activeVideo === video) {
|
|
293
|
+
this.state.volume = video.volume;
|
|
294
|
+
this.state.isMuted = video.muted;
|
|
295
|
+
this._persistMuteState();
|
|
296
|
+
}
|
|
235
297
|
};
|
|
236
298
|
|
|
237
299
|
listeners.error = () => {
|
|
238
300
|
const error = video.error;
|
|
239
301
|
const errorMessage = error ? `${error.code}: ${error.message}` : 'Unknown error';
|
|
240
302
|
|
|
241
|
-
this.
|
|
242
|
-
|
|
303
|
+
if (this.activeVideo === video) {
|
|
304
|
+
this.state.isPlaying = false;
|
|
305
|
+
this._stopPositionUpdates();
|
|
306
|
+
}
|
|
243
307
|
|
|
244
|
-
logger.error(`[VideoManager] Video playback error: ${errorMessage}`, { domain: 'video', operation: 'playback', src:
|
|
308
|
+
logger.error(`[VideoManager] Video playback error: ${errorMessage}`, { domain: 'video', operation: 'playback', src: localState.currentSrc, contextId });
|
|
245
309
|
};
|
|
246
310
|
|
|
247
311
|
// Attach all listeners
|
|
@@ -260,8 +324,14 @@ class VideoManager {
|
|
|
260
324
|
detachVideo(video) {
|
|
261
325
|
if (!video || !video._videoManagerListeners) return;
|
|
262
326
|
|
|
327
|
+
const localState = video._videoManagerState;
|
|
328
|
+
const wasActive = this.activeVideo === video;
|
|
329
|
+
|
|
263
330
|
// Save position before detaching
|
|
264
|
-
if (
|
|
331
|
+
if (wasActive) {
|
|
332
|
+
this.state.position = Number.isFinite(video.currentTime)
|
|
333
|
+
? video.currentTime
|
|
334
|
+
: this.state.position;
|
|
265
335
|
this._savePosition();
|
|
266
336
|
}
|
|
267
337
|
|
|
@@ -272,32 +342,38 @@ class VideoManager {
|
|
|
272
342
|
|
|
273
343
|
delete video._videoManagerListeners;
|
|
274
344
|
delete video._videoManagerContextId;
|
|
275
|
-
|
|
276
|
-
this._stopPositionUpdates();
|
|
345
|
+
delete video._videoManagerState;
|
|
277
346
|
|
|
278
347
|
// Clear state if this was the active video
|
|
279
|
-
if (
|
|
280
|
-
|
|
281
|
-
this.
|
|
282
|
-
|
|
283
|
-
contextId: null,
|
|
284
|
-
contextType: 'standalone',
|
|
285
|
-
position: 0,
|
|
286
|
-
isPlaying: false,
|
|
287
|
-
isMuted: wasMuted,
|
|
288
|
-
duration: 0,
|
|
289
|
-
volume: this.state.volume,
|
|
290
|
-
required: false,
|
|
291
|
-
completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
|
|
292
|
-
isCompleted: false
|
|
293
|
-
};
|
|
294
|
-
this.maxPositionReached = 0;
|
|
348
|
+
if (wasActive) {
|
|
349
|
+
this._stopPositionUpdates();
|
|
350
|
+
this.activeVideo = null;
|
|
351
|
+
this._resetActiveState();
|
|
295
352
|
}
|
|
296
353
|
|
|
297
|
-
eventBus.emit('video:unloaded', { contextType:
|
|
354
|
+
eventBus.emit('video:unloaded', { contextType: localState?.contextType || 'standalone' });
|
|
298
355
|
logger.debug('[VideoManager] Detached video');
|
|
299
356
|
}
|
|
300
357
|
|
|
358
|
+
/** @private */
|
|
359
|
+
_resetActiveState() {
|
|
360
|
+
const { isMuted, volume } = this.state;
|
|
361
|
+
this.state = {
|
|
362
|
+
currentSrc: null,
|
|
363
|
+
contextId: null,
|
|
364
|
+
contextType: 'standalone',
|
|
365
|
+
position: 0,
|
|
366
|
+
isPlaying: false,
|
|
367
|
+
isMuted,
|
|
368
|
+
duration: 0,
|
|
369
|
+
volume,
|
|
370
|
+
required: false,
|
|
371
|
+
completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
|
|
372
|
+
isCompleted: false
|
|
373
|
+
};
|
|
374
|
+
this.maxPositionReached = 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
301
377
|
/**
|
|
302
378
|
* Gets the current video state.
|
|
303
379
|
* @returns {VideoState}
|
|
@@ -404,6 +480,9 @@ class VideoManager {
|
|
|
404
480
|
|
|
405
481
|
if (completionPercentage >= this.state.completionThreshold) {
|
|
406
482
|
this.state.isCompleted = true;
|
|
483
|
+
if (this.activeVideo?._videoManagerState) {
|
|
484
|
+
this.activeVideo._videoManagerState.isCompleted = true;
|
|
485
|
+
}
|
|
407
486
|
this._markContextCompleted(this.state.contextId);
|
|
408
487
|
|
|
409
488
|
logger.debug(`[VideoManager] Video completed: ${this.state.contextId}`);
|
|
@@ -421,7 +500,7 @@ class VideoManager {
|
|
|
421
500
|
* @private
|
|
422
501
|
*/
|
|
423
502
|
_savePosition() {
|
|
424
|
-
if (!this.state.contextId || !this.state.position) return;
|
|
503
|
+
if (!this.state.contextId || !Number.isFinite(this.state.position)) return;
|
|
425
504
|
|
|
426
505
|
this.positionCache.set(this.state.contextId, this.state.position);
|
|
427
506
|
this._persistPositions();
|
|
@@ -698,8 +698,7 @@ async function _performNavigation(slideId, context = {}, updateBookmark = true)
|
|
|
698
698
|
|
|
699
699
|
// Update progress measure to reflect slide visit
|
|
700
700
|
// Count only sequential slides (excludes remedial/conditional slides)
|
|
701
|
-
|
|
702
|
-
stateManager.updateProgressMeasure(totalSequentialSlides);
|
|
701
|
+
syncProgressMeasure();
|
|
703
702
|
|
|
704
703
|
// Set bookmark as FINAL step after successful navigation
|
|
705
704
|
// We use slide ID as the bookmark value (unique, stable, human-readable)
|
|
@@ -880,13 +879,14 @@ export function sync() {
|
|
|
880
879
|
const hasBeenVisited = visitedSlides.includes(slide.id);
|
|
881
880
|
|
|
882
881
|
if (slide.type === 'assessment') {
|
|
883
|
-
const
|
|
882
|
+
const assessmentId = AssessmentManager.resolveAssessmentId(slide);
|
|
883
|
+
const config = assessmentConfigs.get(assessmentId);
|
|
884
884
|
const requirements = config?.completionRequirements;
|
|
885
885
|
let requirementsMet = false;
|
|
886
886
|
|
|
887
887
|
// Only check requirements if they are defined in the assessment's config
|
|
888
888
|
if (requirements) {
|
|
889
|
-
requirementsMet = AssessmentManager.meetsCompletionRequirements(
|
|
889
|
+
requirementsMet = AssessmentManager.meetsCompletionRequirements(assessmentId, requirements);
|
|
890
890
|
}
|
|
891
891
|
|
|
892
892
|
// Mark as "visited" (i.e., show checkmark) only if visited AND requirements are met.
|
|
@@ -1071,6 +1071,20 @@ export function getAllSlides() {
|
|
|
1071
1071
|
return slides;
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
|
+
/**
|
|
1075
|
+
* Recalculates LMS progress using only slides in the learner's active sequence.
|
|
1076
|
+
* Remedial and currently excluded conditional slides must not inflate the
|
|
1077
|
+
* numerator or allow progress_measure to reach 100% early.
|
|
1078
|
+
*/
|
|
1079
|
+
export function syncProgressMeasure() {
|
|
1080
|
+
_requireInitialized();
|
|
1081
|
+
const activeSlides = slides.filter(slide => isSlideInSequence(slide, stateManager, assessmentConfigs));
|
|
1082
|
+
return stateManager.updateProgressMeasure(
|
|
1083
|
+
activeSlides.length,
|
|
1084
|
+
activeSlides.map(slide => slide.id)
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1074
1088
|
/**
|
|
1075
1089
|
* Gets the current slide object.
|
|
1076
1090
|
* @returns {object|null} The current slide, or null if not found.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { iconManager } from '../utilities/icons.js';
|
|
17
17
|
import { logger } from '../utilities/logger.js';
|
|
18
18
|
import { open as openLightbox } from '../components/ui-components/lightbox.js';
|
|
19
|
+
import { resolvePortableAssetUrl } from '../utilities/portable-assets.js';
|
|
19
20
|
|
|
20
21
|
/** @type {HTMLElement|null} */
|
|
21
22
|
let galleryContainer = null;
|
|
@@ -69,14 +70,18 @@ export async function init(courseConfig) {
|
|
|
69
70
|
*/
|
|
70
71
|
async function _fetchManifest() {
|
|
71
72
|
try {
|
|
72
|
-
const response = await fetch('./_gallery-manifest.json');
|
|
73
|
+
const response = await fetch(resolvePortableAssetUrl('./_gallery-manifest.json'));
|
|
73
74
|
if (!response.ok) {
|
|
74
75
|
// Manifest doesn't exist yet (dev mode or no docs) — not an error
|
|
75
76
|
logger.debug('[DocumentGallery] No gallery manifest found (this is normal in dev mode)');
|
|
76
77
|
return null;
|
|
77
78
|
}
|
|
78
79
|
const manifest = await response.json();
|
|
79
|
-
return manifest.items || []
|
|
80
|
+
return (manifest.items || []).map(item => ({
|
|
81
|
+
...item,
|
|
82
|
+
src: resolvePortableAssetUrl(item.src),
|
|
83
|
+
thumbnail: resolvePortableAssetUrl(item.thumbnail)
|
|
84
|
+
}));
|
|
80
85
|
} catch (_error) {
|
|
81
86
|
logger.debug('[DocumentGallery] Could not load gallery manifest');
|
|
82
87
|
return null;
|
|
@@ -146,13 +146,14 @@ export function validateNavigationFrom(slide, assessmentConfigs) {
|
|
|
146
146
|
|
|
147
147
|
// Check if this is an assessment with completion requirements that block navigation
|
|
148
148
|
if (slide.type === 'assessment') {
|
|
149
|
-
const
|
|
149
|
+
const assessmentId = AssessmentManager.resolveAssessmentId(slide);
|
|
150
|
+
const config = assessmentConfigs.get(assessmentId);
|
|
150
151
|
const requirements = config?.completionRequirements;
|
|
151
152
|
|
|
152
153
|
// Only block if blockNavigation is explicitly true
|
|
153
154
|
if (requirements?.blockNavigation === true) {
|
|
154
155
|
const requirementsMet = AssessmentManager.meetsCompletionRequirements(
|
|
155
|
-
|
|
156
|
+
assessmentId,
|
|
156
157
|
requirements
|
|
157
158
|
);
|
|
158
159
|
|
|
@@ -327,21 +327,33 @@ class LMSConnection {
|
|
|
327
327
|
window.addEventListener('pagehide', () => {
|
|
328
328
|
if (this.isTerminated) return;
|
|
329
329
|
|
|
330
|
-
// Startup Guard: Ignore pagehide events < 2s after startup
|
|
331
|
-
if (Date.now() - this.sessionStartTime < 2000) {
|
|
332
|
-
logger.debug('[LMSConnection] Page unload detected immediately after startup (< 2s). Ignoring.');
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
330
|
logger.debug('[LMSConnection] Page unload detected. Attempting emergency save...');
|
|
337
331
|
|
|
338
|
-
|
|
332
|
+
try {
|
|
333
|
+
stateManager.prepareEmergencySave();
|
|
334
|
+
} catch (error) {
|
|
335
|
+
logger.error('[LMSConnection] Failed to stage emergency state', {
|
|
336
|
+
domain: 'state',
|
|
337
|
+
operation: 'prepareEmergencySave',
|
|
338
|
+
format: this.format,
|
|
339
|
+
error: error.message,
|
|
340
|
+
stack: error.stack
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Use the driver's best-effort unload transport when available.
|
|
339
345
|
if (this.driver?.emergencySave) {
|
|
340
346
|
try {
|
|
341
347
|
this.driver.emergencySave();
|
|
342
|
-
logger.debug('[LMSConnection] Emergency save
|
|
343
|
-
} catch (
|
|
344
|
-
logger.
|
|
348
|
+
logger.debug('[LMSConnection] Emergency save staged via driver transport');
|
|
349
|
+
} catch (error) {
|
|
350
|
+
logger.error('[LMSConnection] Driver emergency save failed', {
|
|
351
|
+
domain: 'lms',
|
|
352
|
+
operation: 'emergencySave',
|
|
353
|
+
format: this.format,
|
|
354
|
+
error: error.message,
|
|
355
|
+
stack: error.stack
|
|
356
|
+
});
|
|
345
357
|
}
|
|
346
358
|
return;
|
|
347
359
|
}
|
|
@@ -351,12 +363,24 @@ class LMSConnection {
|
|
|
351
363
|
// Delegate to StateManager to save exit status
|
|
352
364
|
const exitPromise = stateManager.exitCourseWithSuspend();
|
|
353
365
|
if (exitPromise && typeof exitPromise.catch === 'function') {
|
|
354
|
-
exitPromise.catch(
|
|
355
|
-
logger.
|
|
366
|
+
exitPromise.catch(error => {
|
|
367
|
+
logger.error('[LMSConnection] Best-effort unload save did not complete', {
|
|
368
|
+
domain: 'lms',
|
|
369
|
+
operation: 'exitCourseWithSuspend',
|
|
370
|
+
format: this.format,
|
|
371
|
+
error: error.message,
|
|
372
|
+
stack: error.stack
|
|
373
|
+
});
|
|
356
374
|
});
|
|
357
375
|
}
|
|
358
|
-
} catch (
|
|
359
|
-
logger.
|
|
376
|
+
} catch (error) {
|
|
377
|
+
logger.error('[LMSConnection] Best-effort unload save did not start', {
|
|
378
|
+
domain: 'lms',
|
|
379
|
+
operation: 'exitCourseWithSuspend',
|
|
380
|
+
format: this.format,
|
|
381
|
+
error: error.message,
|
|
382
|
+
stack: error.stack
|
|
383
|
+
});
|
|
360
384
|
}
|
|
361
385
|
});
|
|
362
386
|
}
|
|
@@ -368,8 +392,8 @@ class LMSConnection {
|
|
|
368
392
|
_startKeepAlive() {
|
|
369
393
|
this._stopKeepAlive();
|
|
370
394
|
|
|
371
|
-
// Only SCORM formats need keep-alive (
|
|
372
|
-
if (this.format.startsWith('cmi5') || this.format === 'lti') {
|
|
395
|
+
// Only SCORM formats need keep-alive (HTTP and standalone drivers do not)
|
|
396
|
+
if (this.format.startsWith('cmi5') || this.format === 'lti' || this.format === 'standalone') {
|
|
373
397
|
return;
|
|
374
398
|
}
|
|
375
399
|
|
|
@@ -402,6 +426,7 @@ class LMSConnection {
|
|
|
402
426
|
if (this.format === 'scorm1.2') return 'strict-scorm12';
|
|
403
427
|
if (this.format === 'scorm2004') return 'conservative-scorm2004';
|
|
404
428
|
if (this.format.startsWith('cmi5') || this.format === 'lti') return 'modern-http';
|
|
429
|
+
if (this.format === 'standalone') return 'balanced';
|
|
405
430
|
return 'balanced';
|
|
406
431
|
}
|
|
407
432
|
|
|
@@ -10,6 +10,7 @@ import { classifyLmsError } from './lms-error-utils.js';
|
|
|
10
10
|
|
|
11
11
|
// Auto-batch debounce delay in milliseconds
|
|
12
12
|
const AUTO_BATCH_DELAY_MS = 500;
|
|
13
|
+
const MAX_RETRY_DELAY_MS = 60000;
|
|
13
14
|
|
|
14
15
|
export class CommitScheduler {
|
|
15
16
|
/**
|
|
@@ -26,6 +27,7 @@ export class CommitScheduler {
|
|
|
26
27
|
this._autoBatchDirty = false;
|
|
27
28
|
this._autoBatchSuspendDirty = false;
|
|
28
29
|
this._inFlightCommit = null;
|
|
30
|
+
this._retryDelayMs = AUTO_BATCH_DELAY_MS;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -43,7 +45,7 @@ export class CommitScheduler {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
this._autoBatchTimer = setTimeout(() => {
|
|
46
|
-
this._executeCommit();
|
|
48
|
+
void this._executeCommit(false);
|
|
47
49
|
}, AUTO_BATCH_DELAY_MS);
|
|
48
50
|
|
|
49
51
|
logger.debug(`[StateManager] Auto-batch scheduled (${AUTO_BATCH_DELAY_MS}ms debounce)`);
|
|
@@ -60,7 +62,7 @@ export class CommitScheduler {
|
|
|
60
62
|
|
|
61
63
|
if (this._autoBatchDirty) {
|
|
62
64
|
logger.debug('[StateManager] Force-flushing pending writes (critical action)');
|
|
63
|
-
await this._executeCommit();
|
|
65
|
+
await this._executeCommit(true);
|
|
64
66
|
return;
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -148,7 +150,7 @@ export class CommitScheduler {
|
|
|
148
150
|
|
|
149
151
|
// --- Private ---
|
|
150
152
|
|
|
151
|
-
async _executeCommit() {
|
|
153
|
+
async _executeCommit(propagateError = false) {
|
|
152
154
|
this._autoBatchTimer = null;
|
|
153
155
|
|
|
154
156
|
if (!this._autoBatchDirty) return;
|
|
@@ -175,6 +177,7 @@ export class CommitScheduler {
|
|
|
175
177
|
await this._inFlightCommit;
|
|
176
178
|
|
|
177
179
|
logger.debug('[StateManager] Auto-batch committed');
|
|
180
|
+
this._retryDelayMs = AUTO_BATCH_DELAY_MS;
|
|
178
181
|
eventBus.emit('state:commitSuccess');
|
|
179
182
|
eventBus.emit('state:committed');
|
|
180
183
|
} catch (error) {
|
|
@@ -186,14 +189,16 @@ export class CommitScheduler {
|
|
|
186
189
|
}
|
|
187
190
|
logger.error('[StateManager] Auto-batch commit failed:', { domain: 'state', operation: 'autoBatchCommit', stack: error.stack });
|
|
188
191
|
eventBus.emit('state:commitFailed', { error: error.message, classification });
|
|
192
|
+
this._retryDelayMs = Math.min(this._retryDelayMs * 2, MAX_RETRY_DELAY_MS);
|
|
193
|
+
if (propagateError) throw error;
|
|
189
194
|
} finally {
|
|
190
195
|
this._inFlightCommit = null;
|
|
191
196
|
|
|
192
197
|
// If writes happened during commit, schedule follow-up commit.
|
|
193
198
|
if (this._autoBatchDirty && !this._autoBatchTimer) {
|
|
194
199
|
this._autoBatchTimer = setTimeout(() => {
|
|
195
|
-
this._executeCommit();
|
|
196
|
-
},
|
|
200
|
+
void this._executeCommit(false);
|
|
201
|
+
}, this._retryDelayMs);
|
|
197
202
|
}
|
|
198
203
|
}
|
|
199
204
|
}
|