coursecode 0.1.56 → 0.1.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/bin/cli.js +16 -0
- 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/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/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 +131 -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-packaging.js +49 -5
- package/lib/build.js +1 -1
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +28 -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 +6 -2
- 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/package.json +1 -1
- package/template/vite.config.js +50 -22
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file standalone-driver.js
|
|
3
|
+
* @description Local persistence driver for portable, single-file HTML courses.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { HttpDriverBase } from './http-driver-base.js';
|
|
7
|
+
import { logger } from '../utilities/logger.js';
|
|
8
|
+
|
|
9
|
+
function slug(value) {
|
|
10
|
+
return String(value || 'course')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-|-$/g, '')
|
|
14
|
+
.slice(0, 80) || 'course';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class StandaloneDriver extends HttpDriverBase {
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
const courseId = typeof document !== 'undefined'
|
|
21
|
+
? (document.querySelector('meta[name="coursecode-id"]')?.content || document.title)
|
|
22
|
+
: 'course';
|
|
23
|
+
this._storageKey = `coursecode-portable:${slug(courseId)}`;
|
|
24
|
+
this._storageAvailable = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getFormat() {
|
|
28
|
+
return 'standalone';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getCapabilities() {
|
|
32
|
+
return {
|
|
33
|
+
supportsObjectives: true,
|
|
34
|
+
supportsInteractions: true,
|
|
35
|
+
supportsComments: true,
|
|
36
|
+
supportsEmergencySave: true,
|
|
37
|
+
maxSuspendDataBytes: 0,
|
|
38
|
+
asyncCommit: false
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async initialize() {
|
|
43
|
+
if (this._isConnected) return true;
|
|
44
|
+
this._mock = true;
|
|
45
|
+
this._loadMockState();
|
|
46
|
+
this._isConnected = true;
|
|
47
|
+
this._isTerminated = false;
|
|
48
|
+
logger.debug('[StandaloneDriver] Portable course initialized', {
|
|
49
|
+
restoreAvailable: this._storageAvailable,
|
|
50
|
+
resumed: Boolean(this._bookmarkCache)
|
|
51
|
+
});
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async terminate() {
|
|
56
|
+
if (!this._isConnected || this._isTerminated) return true;
|
|
57
|
+
this._saveMockState();
|
|
58
|
+
this._isTerminated = true;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
emergencySave() {
|
|
63
|
+
this._saveMockState();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getLearnerInfo() {
|
|
67
|
+
return { id: 'local-learner', name: 'Learner' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getLaunchData() {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async _persistState() {
|
|
75
|
+
this._saveMockState();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_loadMockState() {
|
|
79
|
+
let stored;
|
|
80
|
+
try {
|
|
81
|
+
stored = localStorage.getItem(this._storageKey);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this._storageAvailable = false;
|
|
84
|
+
logger.warn('[StandaloneDriver] Browser restore storage is unavailable; progress will last for this session only', {
|
|
85
|
+
domain: 'standalone',
|
|
86
|
+
operation: 'loadState',
|
|
87
|
+
error: error.message
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!stored) return;
|
|
93
|
+
try {
|
|
94
|
+
const state = JSON.parse(stored);
|
|
95
|
+
if (!state || typeof state !== 'object') return;
|
|
96
|
+
|
|
97
|
+
this._mockState = state;
|
|
98
|
+
this._bookmarkCache = state.bookmark || null;
|
|
99
|
+
this._completionStatus = state.completionStatus || 'unknown';
|
|
100
|
+
this._successStatus = state.successStatus || 'unknown';
|
|
101
|
+
this._score = state.score ?? null;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this._mockState = {};
|
|
104
|
+
logger.warn('[StandaloneDriver] Ignoring invalid saved progress and starting a new local session', {
|
|
105
|
+
domain: 'standalone',
|
|
106
|
+
operation: 'parseState',
|
|
107
|
+
error: error.message
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
localStorage.removeItem(this._storageKey);
|
|
111
|
+
} catch {
|
|
112
|
+
this._storageAvailable = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_saveMockState() {
|
|
118
|
+
const state = {
|
|
119
|
+
...this._mockState,
|
|
120
|
+
bookmark: this._bookmarkCache,
|
|
121
|
+
completionStatus: this._completionStatus,
|
|
122
|
+
successStatus: this._successStatus,
|
|
123
|
+
score: this._score
|
|
124
|
+
};
|
|
125
|
+
this._mockState = state;
|
|
126
|
+
|
|
127
|
+
if (!this._storageAvailable) return;
|
|
128
|
+
try {
|
|
129
|
+
localStorage.setItem(this._storageKey, JSON.stringify(state));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this._storageAvailable = false;
|
|
132
|
+
logger.warn('[StandaloneDriver] Could not persist portable course progress', {
|
|
133
|
+
domain: 'standalone',
|
|
134
|
+
operation: 'saveState',
|
|
135
|
+
error: error.message
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -16,34 +16,49 @@ import { logger } from '../utilities/logger.js';
|
|
|
16
16
|
/**
|
|
17
17
|
* Creates a batch registration function that sets tracked[totalField] = ids.length.
|
|
18
18
|
*/
|
|
19
|
-
function makeRegister(totalField, label) {
|
|
19
|
+
function makeRegister(totalField, label, registeredField = null) {
|
|
20
20
|
// Derive the viewed field name from the total field (e.g., 'tabsTotal' → 'tabsViewed')
|
|
21
21
|
const viewedField = totalField.replace('Total', 'Viewed');
|
|
22
22
|
return function (slideId, ids) {
|
|
23
23
|
if (!slideId || !Array.isArray(ids)) return;
|
|
24
24
|
const state = this._getState();
|
|
25
25
|
if (!state[slideId]) return;
|
|
26
|
-
state[slideId].tracked
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const tracked = state[slideId].tracked;
|
|
27
|
+
const registeredIds = [...new Set(ids.filter(Boolean))];
|
|
28
|
+
const registeredIdSet = new Set(registeredIds);
|
|
29
|
+
|
|
30
|
+
tracked[totalField] = registeredIds.length;
|
|
31
|
+
if (registeredField) {
|
|
32
|
+
tracked[registeredField] = registeredIds;
|
|
33
|
+
}
|
|
34
|
+
// Views render fresh during navigation. Preserve valid persisted progress
|
|
35
|
+
// while pruning IDs that no longer exist in the authored content.
|
|
36
|
+
tracked[viewedField] = [...new Set(tracked[viewedField] || [])]
|
|
37
|
+
.filter(id => registeredIdSet.has(id));
|
|
29
38
|
this._setState(state);
|
|
30
|
-
logger.debug(`[EngagementManager] Registered ${
|
|
39
|
+
logger.debug(`[EngagementManager] Registered ${registeredIds.length} ${label}: ${slideId}`);
|
|
31
40
|
this._checkAndEmitProgress(slideId);
|
|
32
41
|
};
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
|
-
* Creates
|
|
45
|
+
* Creates a deduplicating incremental registration function for components
|
|
46
|
+
* that can register independently within the same rendered view.
|
|
37
47
|
*/
|
|
38
|
-
function makeRegisterIncremental(totalField,
|
|
48
|
+
function makeRegisterIncremental(totalField, registeredField, label) {
|
|
49
|
+
const viewedField = totalField.replace('Total', 'Viewed');
|
|
39
50
|
return function (slideId, ids) {
|
|
40
51
|
if (!slideId || !Array.isArray(ids)) return;
|
|
41
52
|
const state = this._getState();
|
|
42
53
|
if (!state[slideId]) return;
|
|
43
|
-
|
|
44
|
-
if (!
|
|
45
|
-
|
|
54
|
+
const tracked = state[slideId].tracked;
|
|
55
|
+
if (!Array.isArray(tracked[viewedField])) {
|
|
56
|
+
tracked[viewedField] = [];
|
|
46
57
|
}
|
|
58
|
+
const registeredIds = new Set(tracked[registeredField] || []);
|
|
59
|
+
ids.filter(Boolean).forEach(id => registeredIds.add(id));
|
|
60
|
+
tracked[registeredField] = [...registeredIds];
|
|
61
|
+
tracked[totalField] = registeredIds.size;
|
|
47
62
|
this._setState(state);
|
|
48
63
|
logger.debug(`[EngagementManager] Registered ${ids.length} ${label}: ${slideId}`);
|
|
49
64
|
this._checkAndEmitProgress(slideId);
|
|
@@ -101,10 +116,16 @@ export const registerTimeline = makeRegister('timelineEventsTotal', 'timeline ev
|
|
|
101
116
|
export const registerModals = makeRegister('modalsTotal', 'modals');
|
|
102
117
|
|
|
103
118
|
export const registerInteractiveImage = makeRegisterIncremental(
|
|
104
|
-
'interactiveImageHotspotsTotal', '
|
|
119
|
+
'interactiveImageHotspotsTotal', 'interactiveImageHotspotsRegistered', 'hotspots'
|
|
105
120
|
);
|
|
106
121
|
export const registerLightbox = makeRegisterIncremental(
|
|
107
|
-
'lightboxesTotal', '
|
|
122
|
+
'lightboxesTotal', 'lightboxesRegistered', 'lightboxes'
|
|
123
|
+
);
|
|
124
|
+
export const registerInteractiveImages = makeRegister(
|
|
125
|
+
'interactiveImageHotspotsTotal', 'hotspots', 'interactiveImageHotspotsRegistered'
|
|
126
|
+
);
|
|
127
|
+
export const registerLightboxes = makeRegister(
|
|
128
|
+
'lightboxesTotal', 'lightboxes', 'lightboxesRegistered'
|
|
108
129
|
);
|
|
109
130
|
|
|
110
131
|
// =========================================================================
|
|
@@ -153,6 +174,10 @@ export const trackSlideVideoComplete = makeBoolTracker('videoComplete', 'Slide v
|
|
|
153
174
|
/** Interaction tracking — uses object map keyed by interactionId, not array. */
|
|
154
175
|
export function trackInteraction(slideId, interactionId, completed, correct) {
|
|
155
176
|
if (!slideId || !interactionId) return;
|
|
177
|
+
if (typeof completed !== 'boolean' || typeof correct !== 'boolean') {
|
|
178
|
+
logger.warn(`[EngagementManager] Ignoring invalid interaction state for ${interactionId}; completed and correct must be booleans`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
156
181
|
const state = this._getState();
|
|
157
182
|
if (!state[slideId]) return;
|
|
158
183
|
state[slideId].tracked.interactionsCompleted[interactionId] = { completed, correct };
|
|
@@ -163,7 +188,7 @@ export function trackInteraction(slideId, interactionId, completed, correct) {
|
|
|
163
188
|
|
|
164
189
|
/** Scroll depth — numeric high-water mark, not array/boolean. */
|
|
165
190
|
export function trackScrollDepth(slideId, percentage) {
|
|
166
|
-
if (!slideId ||
|
|
191
|
+
if (!slideId || !Number.isFinite(percentage)) return;
|
|
167
192
|
const state = this._getState();
|
|
168
193
|
if (!state[slideId]) return;
|
|
169
194
|
const currentDepth = state[slideId].tracked.scrollDepth;
|
|
@@ -250,7 +250,11 @@ export default strategies;
|
|
|
250
250
|
export const validTypes = Object.keys(strategies);
|
|
251
251
|
|
|
252
252
|
// ── Core fields not owned by any single strategy ────────────────────
|
|
253
|
-
const CORE_FIELDS = {
|
|
253
|
+
const CORE_FIELDS = {
|
|
254
|
+
flipCardsRegistered: [],
|
|
255
|
+
interactiveImageHotspotsRegistered: [],
|
|
256
|
+
lightboxesRegistered: []
|
|
257
|
+
};
|
|
254
258
|
|
|
255
259
|
/**
|
|
256
260
|
* Aggregate all tracked field defaults from strategy declarations.
|
package/framework/js/main.js
CHANGED
|
@@ -51,6 +51,12 @@ import { initErrorReporter } from './utilities/error-reporter.js';
|
|
|
51
51
|
import { initDataReporter, reportData } from './utilities/data-reporter.js';
|
|
52
52
|
import { initCourseChannel } from './utilities/course-channel.js';
|
|
53
53
|
import { canvasSlide } from './utilities/canvas-slide.js';
|
|
54
|
+
import { hydratePortableAssetObject } from './utilities/portable-assets.js';
|
|
55
|
+
|
|
56
|
+
// Portable exports inject their asset map before this module runs. Hydrating
|
|
57
|
+
// config here covers branding and programmatic media sources before managers
|
|
58
|
+
// cache any paths. This is a no-op in LMS and preview builds.
|
|
59
|
+
hydratePortableAssetObject(courseConfig);
|
|
54
60
|
|
|
55
61
|
// Expose framework modules globally IMMEDIATELY for bundled course slides
|
|
56
62
|
// This MUST happen before any slide code executes (which happens during glob import)
|
|
@@ -615,6 +621,7 @@ async function initializeCourseApplication() {
|
|
|
615
621
|
await audioManager.load(slide.audio, toSlideId, 'slide');
|
|
616
622
|
logger.debug(`[AudioManager] Loaded audio for slide: ${toSlideId}`);
|
|
617
623
|
} catch (error) {
|
|
624
|
+
if (error.name === 'AbortError') return;
|
|
618
625
|
logger.error(`[AudioManager] Failed to load audio for slide ${toSlideId}:`, error);
|
|
619
626
|
// Continue - don't let audio errors break navigation
|
|
620
627
|
}
|
|
@@ -17,6 +17,21 @@
|
|
|
17
17
|
import { deepMerge } from '../utilities/utilities.js';
|
|
18
18
|
import stateManager from '../state/index.js';
|
|
19
19
|
import { createAssessmentInstance } from '../assessment/AssessmentFactory.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the runtime assessment ID from a derived slide entry or a raw
|
|
23
|
+
* course-structure entry. CourseHelpers normally supplies assessmentId from
|
|
24
|
+
* the assessment module; slide.id is the documented authoring fallback.
|
|
25
|
+
* @param {object} slide
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function resolveAssessmentId(slide) {
|
|
29
|
+
const assessmentId = slide?.assessmentId || slide?.id;
|
|
30
|
+
if (!assessmentId || typeof assessmentId !== 'string') {
|
|
31
|
+
throw new Error('AssessmentManager: assessment slide requires assessmentId or id');
|
|
32
|
+
}
|
|
33
|
+
return assessmentId;
|
|
34
|
+
}
|
|
20
35
|
import { logger } from '../utilities/logger.js';
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -190,7 +205,7 @@ export function allAssessmentsMeetRequirements(assessmentSlides = []) {
|
|
|
190
205
|
}
|
|
191
206
|
|
|
192
207
|
return assessmentsWithRequirements.every(slide =>
|
|
193
|
-
meetsCompletionRequirements(slide
|
|
208
|
+
meetsCompletionRequirements(resolveAssessmentId(slide), slide.assessment.completionRequirements)
|
|
194
209
|
);
|
|
195
210
|
}
|
|
196
211
|
|
|
@@ -18,6 +18,11 @@
|
|
|
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 { resolvePortableAssetUrl } from '../utilities/portable-assets.js';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_MEDIA_COMPLETION_THRESHOLD,
|
|
24
|
+
normalizeCompletionThreshold
|
|
25
|
+
} from '../utilities/media-utils.js';
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* @typedef {Object} AudioState
|
|
@@ -42,9 +47,6 @@ import { logger } from '../utilities/logger.js';
|
|
|
42
47
|
* @property {number} [completionThreshold=0.95] - Percentage (0-1) required for completion
|
|
43
48
|
*/
|
|
44
49
|
|
|
45
|
-
/** Default completion threshold (95%) */
|
|
46
|
-
const DEFAULT_COMPLETION_THRESHOLD = 0.95;
|
|
47
|
-
|
|
48
50
|
class AudioManager {
|
|
49
51
|
constructor() {
|
|
50
52
|
/** @type {HTMLAudioElement|null} */
|
|
@@ -61,7 +63,7 @@ class AudioManager {
|
|
|
61
63
|
duration: 0,
|
|
62
64
|
volume: 1,
|
|
63
65
|
required: false,
|
|
64
|
-
completionThreshold:
|
|
66
|
+
completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
|
|
65
67
|
isCompleted: false
|
|
66
68
|
};
|
|
67
69
|
|
|
@@ -85,6 +87,9 @@ class AudioManager {
|
|
|
85
87
|
|
|
86
88
|
/** @type {Function|null} - Cleanup function for pending load operation */
|
|
87
89
|
this._pendingLoadCleanup = null;
|
|
90
|
+
|
|
91
|
+
/** @type {Function|null} - Rejects a pending load when its source is replaced */
|
|
92
|
+
this._pendingLoadCancel = null;
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/**
|
|
@@ -262,7 +267,7 @@ class AudioManager {
|
|
|
262
267
|
this.state.isPlaying = false; // Reset - new audio isn't playing
|
|
263
268
|
this.state.position = 0; // Reset position for new audio
|
|
264
269
|
this.state.required = config.required || false;
|
|
265
|
-
this.state.completionThreshold = config.completionThreshold
|
|
270
|
+
this.state.completionThreshold = normalizeCompletionThreshold(config.completionThreshold);
|
|
266
271
|
this.maxPositionReached = 0;
|
|
267
272
|
|
|
268
273
|
// Check if already completed (from previous session)
|
|
@@ -279,10 +284,7 @@ class AudioManager {
|
|
|
279
284
|
});
|
|
280
285
|
|
|
281
286
|
// Clean up any pending load operation before starting new one
|
|
282
|
-
|
|
283
|
-
this._pendingLoadCleanup();
|
|
284
|
-
this._pendingLoadCleanup = null;
|
|
285
|
-
}
|
|
287
|
+
this._cancelPendingLoad();
|
|
286
288
|
|
|
287
289
|
// Set flag to ignore MEDIA_ERR_ABORTED during source switch
|
|
288
290
|
this._isSwitchingSource = true;
|
|
@@ -353,11 +355,25 @@ class AudioManager {
|
|
|
353
355
|
const cleanup = () => {
|
|
354
356
|
this.audio.removeEventListener('canplaythrough', onLoaded);
|
|
355
357
|
this.audio.removeEventListener('error', onError);
|
|
356
|
-
this._pendingLoadCleanup
|
|
358
|
+
if (this._pendingLoadCleanup === cleanup) {
|
|
359
|
+
this._pendingLoadCleanup = null;
|
|
360
|
+
this._pendingLoadCancel = null;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const cancel = () => {
|
|
365
|
+
if (resolved) return;
|
|
366
|
+
resolved = true;
|
|
367
|
+
this._isSwitchingSource = false;
|
|
368
|
+
cleanup();
|
|
369
|
+
const error = new Error(`Audio load cancelled: ${audioSrc}`);
|
|
370
|
+
error.name = 'AbortError';
|
|
371
|
+
reject(error);
|
|
357
372
|
};
|
|
358
373
|
|
|
359
374
|
// Store cleanup function so it can be called if load is cancelled
|
|
360
375
|
this._pendingLoadCleanup = cleanup;
|
|
376
|
+
this._pendingLoadCancel = cancel;
|
|
361
377
|
|
|
362
378
|
// Add event listeners BEFORE setting src
|
|
363
379
|
this.audio.addEventListener('canplaythrough', onLoaded);
|
|
@@ -455,6 +471,10 @@ class AudioManager {
|
|
|
455
471
|
*/
|
|
456
472
|
seek(position) {
|
|
457
473
|
this._requireInitialized();
|
|
474
|
+
|
|
475
|
+
if (!Number.isFinite(position)) {
|
|
476
|
+
throw new Error(`AudioManager.seek: position must be a finite number, got ${position}`);
|
|
477
|
+
}
|
|
458
478
|
|
|
459
479
|
if (!this.state.currentSrc || !this.audio.duration) {
|
|
460
480
|
return;
|
|
@@ -533,10 +553,7 @@ class AudioManager {
|
|
|
533
553
|
this._savePosition();
|
|
534
554
|
|
|
535
555
|
// Clean up any pending load operation
|
|
536
|
-
|
|
537
|
-
this._pendingLoadCleanup();
|
|
538
|
-
this._pendingLoadCleanup = null;
|
|
539
|
-
}
|
|
556
|
+
this._cancelPendingLoad();
|
|
540
557
|
|
|
541
558
|
// Save contextType before clearing state (needed for event)
|
|
542
559
|
const contextType = this.state.contextType;
|
|
@@ -569,7 +586,7 @@ class AudioManager {
|
|
|
569
586
|
duration: 0,
|
|
570
587
|
volume: this.state.volume,
|
|
571
588
|
required: false,
|
|
572
|
-
completionThreshold:
|
|
589
|
+
completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
|
|
573
590
|
isCompleted: false
|
|
574
591
|
};
|
|
575
592
|
this.maxPositionReached = 0;
|
|
@@ -578,6 +595,20 @@ class AudioManager {
|
|
|
578
595
|
eventBus.emit('audio:unloaded', { contextType });
|
|
579
596
|
}
|
|
580
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Settles and cleans up an in-progress load before replacing its source.
|
|
600
|
+
* @private
|
|
601
|
+
*/
|
|
602
|
+
_cancelPendingLoad() {
|
|
603
|
+
if (this._pendingLoadCancel) {
|
|
604
|
+
this._pendingLoadCancel();
|
|
605
|
+
} else if (this._pendingLoadCleanup) {
|
|
606
|
+
this._pendingLoadCleanup();
|
|
607
|
+
this._pendingLoadCleanup = null;
|
|
608
|
+
}
|
|
609
|
+
this._pendingLoadCancel = null;
|
|
610
|
+
}
|
|
611
|
+
|
|
581
612
|
/**
|
|
582
613
|
* Gets the current audio state.
|
|
583
614
|
* @returns {AudioState}
|
|
@@ -679,6 +710,9 @@ class AudioManager {
|
|
|
679
710
|
}
|
|
680
711
|
}
|
|
681
712
|
}
|
|
713
|
+
|
|
714
|
+
const portableSrc = resolvePortableAssetUrl(resolvedSrc);
|
|
715
|
+
if (portableSrc !== resolvedSrc) return portableSrc;
|
|
682
716
|
|
|
683
717
|
// If already a full path or URL, return as-is
|
|
684
718
|
if (resolvedSrc.startsWith('http') || resolvedSrc.startsWith('/') || resolvedSrc.startsWith('./')) {
|
|
@@ -760,7 +794,7 @@ class AudioManager {
|
|
|
760
794
|
* @private
|
|
761
795
|
*/
|
|
762
796
|
_savePosition() {
|
|
763
|
-
if (!this.state.contextId || !this.state.position) return;
|
|
797
|
+
if (!this.state.contextId || !Number.isFinite(this.state.position)) return;
|
|
764
798
|
|
|
765
799
|
this.positionCache.set(this.state.contextId, this.state.position);
|
|
766
800
|
this._persistPositions();
|
|
@@ -48,6 +48,7 @@ class FlagManager {
|
|
|
48
48
|
* @returns {any} The value of the flag, or undefined if not found.
|
|
49
49
|
*/
|
|
50
50
|
getFlag(key) {
|
|
51
|
+
if (!Object.hasOwn(this.flags, key)) return undefined;
|
|
51
52
|
const val = this.flags[key];
|
|
52
53
|
return (val && typeof val === 'object') ? deepClone(val) : val;
|
|
53
54
|
}
|
|
@@ -62,6 +63,9 @@ class FlagManager {
|
|
|
62
63
|
if (typeof key !== 'string' || key.trim() === '') {
|
|
63
64
|
throw new Error('FlagManager: setFlag requires a non-empty string key.');
|
|
64
65
|
}
|
|
66
|
+
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
|
67
|
+
throw new Error(`FlagManager: reserved flag key "${key}" is not allowed.`);
|
|
68
|
+
}
|
|
65
69
|
|
|
66
70
|
this.flags[key] = value;
|
|
67
71
|
|
|
@@ -74,7 +78,7 @@ class FlagManager {
|
|
|
74
78
|
* @param {string} key - The key for the flag to remove.
|
|
75
79
|
*/
|
|
76
80
|
removeFlag(key) {
|
|
77
|
-
if (this.flags
|
|
81
|
+
if (Object.hasOwn(this.flags, key)) {
|
|
78
82
|
delete this.flags[key];
|
|
79
83
|
stateManager.setDomainState(this.DOMAIN_KEY, this.flags, { source: this.SOURCE });
|
|
80
84
|
eventBus.emit('flag:removed', { key });
|
|
@@ -147,6 +147,15 @@ class ObjectiveManager {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
if (objectiveData.success_status !== undefined &&
|
|
151
|
+
!ObjectiveManager.VALID_SUCCESS_STATUSES.includes(objectiveData.success_status)) {
|
|
152
|
+
throw new Error(`ObjectiveManager: Invalid success_status "${objectiveData.success_status}". Must be one of: ${ObjectiveManager.VALID_SUCCESS_STATUSES.join(', ')}`);
|
|
153
|
+
}
|
|
154
|
+
if (objectiveData.completion_status !== undefined &&
|
|
155
|
+
!ObjectiveManager.VALID_COMPLETION_STATUSES.includes(objectiveData.completion_status)) {
|
|
156
|
+
throw new Error(`ObjectiveManager: Invalid completion_status "${objectiveData.completion_status}". Must be one of: ${ObjectiveManager.VALID_COMPLETION_STATUSES.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
150
159
|
const existing = this.objectives[id] || { id };
|
|
151
160
|
const updated = { ...existing, ...objectiveData };
|
|
152
161
|
|
|
@@ -178,7 +187,7 @@ class ObjectiveManager {
|
|
|
178
187
|
* Valid completion status values per SCORM spec.
|
|
179
188
|
* @private
|
|
180
189
|
*/
|
|
181
|
-
static VALID_COMPLETION_STATUSES = ['completed', 'incomplete'];
|
|
190
|
+
static VALID_COMPLETION_STATUSES = ['completed', 'incomplete', 'not attempted', 'unknown'];
|
|
182
191
|
|
|
183
192
|
/**
|
|
184
193
|
* A helper method to specifically update the success status of an objective.
|
|
@@ -200,12 +209,11 @@ class ObjectiveManager {
|
|
|
200
209
|
if (!objective) {
|
|
201
210
|
throw new Error(`ObjectiveManager: Objective with id "${id}" not found.`);
|
|
202
211
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
212
|
+
this.setObjective({
|
|
213
|
+
...objective,
|
|
214
|
+
success_status,
|
|
215
|
+
...(score !== null ? { score } : {})
|
|
216
|
+
});
|
|
209
217
|
}
|
|
210
218
|
|
|
211
219
|
/**
|
|
@@ -100,10 +100,24 @@ class ScoreManager {
|
|
|
100
100
|
throw new Error('[ScoreManager] Configuration must include non-empty "sources" array');
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
const sourceIds = config.sources.map(source =>
|
|
104
|
+
typeof source === 'string' ? source : source?.id
|
|
105
|
+
);
|
|
106
|
+
const invalidSourceId = sourceIds.find(sourceId =>
|
|
107
|
+
typeof sourceId !== 'string' || !/^(assessment|objective):.+/.test(sourceId)
|
|
108
|
+
);
|
|
109
|
+
if (invalidSourceId !== undefined) {
|
|
110
|
+
throw new Error(`[ScoreManager] Invalid source ID "${invalidSourceId}". Use "assessment:<id>" or "objective:<id>".`);
|
|
111
|
+
}
|
|
112
|
+
if (new Set(sourceIds).size !== sourceIds.length) {
|
|
113
|
+
throw new Error('[ScoreManager] Scoring source IDs must be unique');
|
|
114
|
+
}
|
|
115
|
+
|
|
103
116
|
// Validate weighted sources
|
|
104
117
|
if (config.type === 'weighted') {
|
|
105
118
|
const hasInvalidSources = config.sources.some(source =>
|
|
106
|
-
typeof source !== 'object' || !source.id ||
|
|
119
|
+
!source || typeof source !== 'object' || !source.id ||
|
|
120
|
+
!Number.isFinite(source.weight) || source.weight < 0 || source.weight > 1
|
|
107
121
|
);
|
|
108
122
|
if (hasInvalidSources) {
|
|
109
123
|
throw new Error('[ScoreManager] Weighted scoring requires sources with {id, weight} format');
|
|
@@ -167,7 +181,7 @@ class ScoreManager {
|
|
|
167
181
|
// Load objective score from ObjectiveManager
|
|
168
182
|
try {
|
|
169
183
|
const objective = objectiveManager.getObjective(id);
|
|
170
|
-
if (objective &&
|
|
184
|
+
if (objective && Number.isFinite(objective.score) && objective.score >= 0 && objective.score <= 100) {
|
|
171
185
|
this.cachedScores[sourceId] = objective.score;
|
|
172
186
|
}
|
|
173
187
|
} catch (_error) {
|
|
@@ -179,7 +193,10 @@ class ScoreManager {
|
|
|
179
193
|
const domainKey = `assessment_${id}`;
|
|
180
194
|
const assessmentState = stateManager.getDomainState(domainKey);
|
|
181
195
|
const summary = assessmentState?.summary;
|
|
182
|
-
if (summary && summary.lastResults &&
|
|
196
|
+
if (summary && summary.lastResults &&
|
|
197
|
+
Number.isFinite(summary.lastResults.scorePercentage) &&
|
|
198
|
+
summary.lastResults.scorePercentage >= 0 &&
|
|
199
|
+
summary.lastResults.scorePercentage <= 100) {
|
|
183
200
|
this.cachedScores[sourceId] = summary.lastResults.scorePercentage;
|
|
184
201
|
}
|
|
185
202
|
} catch (_error) {
|
|
@@ -200,6 +217,10 @@ class ScoreManager {
|
|
|
200
217
|
_updateSourceScore(sourceId, score) {
|
|
201
218
|
if (!this.isInitialized) return;
|
|
202
219
|
|
|
220
|
+
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
|
221
|
+
throw new Error(`[ScoreManager] Source score ${score} is out of range [0-100].`);
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
// Check if this source is configured
|
|
204
225
|
const sourceIds = this.config.sources.map(source =>
|
|
205
226
|
typeof source === 'string' ? source : source.id
|
|
@@ -213,7 +234,7 @@ class ScoreManager {
|
|
|
213
234
|
const oldScore = this.cachedScores[sourceId];
|
|
214
235
|
this.cachedScores[sourceId] = score;
|
|
215
236
|
|
|
216
|
-
logger.debug(`[ScoreManager] Score updated: ${sourceId} = ${score} (was ${oldScore
|
|
237
|
+
logger.debug(`[ScoreManager] Score updated: ${sourceId} = ${score} (was ${oldScore ?? 'none'})`);
|
|
217
238
|
|
|
218
239
|
// Recalculate and report course score
|
|
219
240
|
this._calculateAndReportScore();
|
|
@@ -234,7 +255,7 @@ class ScoreManager {
|
|
|
234
255
|
}
|
|
235
256
|
|
|
236
257
|
// Validate score range
|
|
237
|
-
if (calculatedScore < 0 || calculatedScore > 100
|
|
258
|
+
if (!Number.isFinite(calculatedScore) || calculatedScore < 0 || calculatedScore > 100) {
|
|
238
259
|
throw new Error(`[ScoreManager] Calculated score ${calculatedScore} is out of range [0-100]. This indicates a bug in the scoring calculation.`);
|
|
239
260
|
}
|
|
240
261
|
|
|
@@ -381,7 +402,7 @@ class ScoreManager {
|
|
|
381
402
|
return null;
|
|
382
403
|
}
|
|
383
404
|
|
|
384
|
-
if (
|
|
405
|
+
if (!Number.isFinite(result)) {
|
|
385
406
|
throw new Error(`[ScoreManager] Custom calculate function must return a number or null. Got: ${typeof result}`);
|
|
386
407
|
}
|
|
387
408
|
|