coursecode 0.1.0
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/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- package/template/vite.config.js +339 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file state-validation.js
|
|
3
|
+
* @description State hydration, validation, and migration logic.
|
|
4
|
+
* Validates stored LMS state against current course structure.
|
|
5
|
+
* @internal Only used by state-manager.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { eventBus } from '../core/event-bus.js';
|
|
9
|
+
import { logger } from '../utilities/logger.js';
|
|
10
|
+
|
|
11
|
+
// =================================================================
|
|
12
|
+
// State Schema Version
|
|
13
|
+
// =================================================================
|
|
14
|
+
// Increment this when the state structure changes in incompatible ways.
|
|
15
|
+
const STATE_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
// Migration functions keyed by TARGET version number.
|
|
18
|
+
// Each migration transforms state from (version - 1) to (version).
|
|
19
|
+
const STATE_MIGRATIONS = {
|
|
20
|
+
// No migrations yet - add here when STATE_SCHEMA_VERSION is incremented
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Handles state validation mismatches.
|
|
25
|
+
* - In dev mode: Throws an error with detailed diagnostics
|
|
26
|
+
* - In prod mode: Logs warning and returns the default value for graceful recovery
|
|
27
|
+
*/
|
|
28
|
+
function handleStateMismatch(domain, message, context, defaultValue) {
|
|
29
|
+
const fullMessage = `[StateManager] State mismatch in "${domain}": ${message}`;
|
|
30
|
+
|
|
31
|
+
if (import.meta.env.DEV) {
|
|
32
|
+
logger.fatal(fullMessage, {
|
|
33
|
+
domain: 'state',
|
|
34
|
+
operation: 'validation',
|
|
35
|
+
...context,
|
|
36
|
+
hint: 'This error occurs when stored LMS data is incompatible with the current course structure. ' +
|
|
37
|
+
'Clear your LMS data or use a fresh learner account to test the updated course.'
|
|
38
|
+
});
|
|
39
|
+
} else {
|
|
40
|
+
logger.warn(`${fullMessage}. Reverting to defaults.`, context);
|
|
41
|
+
eventBus.emit('state:recovered', {
|
|
42
|
+
domain,
|
|
43
|
+
message,
|
|
44
|
+
context,
|
|
45
|
+
action: 'reverted_to_defaults'
|
|
46
|
+
});
|
|
47
|
+
return defaultValue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class StateValidator {
|
|
52
|
+
constructor() {
|
|
53
|
+
this._validationConfig = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get schemaVersion() {
|
|
57
|
+
return STATE_SCHEMA_VERSION;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sets the course configuration used for state validation.
|
|
62
|
+
* Must be called BEFORE hydration to enable validation.
|
|
63
|
+
*/
|
|
64
|
+
setCourseValidationConfig(config) {
|
|
65
|
+
if (!config || typeof config !== 'object') {
|
|
66
|
+
throw new Error('StateManager: validation config must be an object');
|
|
67
|
+
}
|
|
68
|
+
if (!config.structure || !Array.isArray(config.structure)) {
|
|
69
|
+
throw new Error('StateManager: validation config must include a structure array');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const slideIds = new Set();
|
|
73
|
+
const interactionIdsBySlide = new Map();
|
|
74
|
+
|
|
75
|
+
const processItem = (item) => {
|
|
76
|
+
if (item.id) {
|
|
77
|
+
slideIds.add(item.id);
|
|
78
|
+
}
|
|
79
|
+
if (item.children && Array.isArray(item.children)) {
|
|
80
|
+
item.children.forEach(processItem);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
config.structure.forEach(processItem);
|
|
84
|
+
|
|
85
|
+
const objectiveIds = new Set();
|
|
86
|
+
if (config.objectives && Array.isArray(config.objectives)) {
|
|
87
|
+
config.objectives.forEach(obj => {
|
|
88
|
+
if (obj.id) objectiveIds.add(obj.id);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this._validationConfig = {
|
|
93
|
+
slideIds,
|
|
94
|
+
objectiveIds,
|
|
95
|
+
interactionIdsBySlide,
|
|
96
|
+
courseVersion: config.version || null,
|
|
97
|
+
schemaVersion: STATE_SCHEMA_VERSION
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
logger.debug(`[StateManager] Validation config set: ${slideIds.size} slides, ${objectiveIds.size} objectives`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Creates a fresh state object with schema version.
|
|
105
|
+
*/
|
|
106
|
+
createFreshState() {
|
|
107
|
+
return {
|
|
108
|
+
_meta: {
|
|
109
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
110
|
+
createdAt: new Date().toISOString()
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hydrates state from the LMS using semantic driver reads.
|
|
117
|
+
* @param {Object} lmsConnection - The LMS connection instance
|
|
118
|
+
* @returns {Object} The hydrated state
|
|
119
|
+
*/
|
|
120
|
+
hydrateStateFromLMS(lmsConnection) {
|
|
121
|
+
let entryMode;
|
|
122
|
+
try {
|
|
123
|
+
entryMode = lmsConnection.getEntryMode();
|
|
124
|
+
logger.debug(`[StateManager] Entry mode: "${entryMode}"`);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`StateManager: Cannot read entry mode. LMS connection may not be initialized. Error: ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (entryMode === 'ab-initio') {
|
|
130
|
+
logger.debug('[StateManager] Fresh session (ab-initio), starting with empty state');
|
|
131
|
+
return this.createFreshState();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const suspendData = lmsConnection.getSuspendData();
|
|
135
|
+
if (suspendData) {
|
|
136
|
+
const state = this.validateAndMigrateState(suspendData);
|
|
137
|
+
logger.debug('[StateManager] Hydrated state from suspend_data');
|
|
138
|
+
logger.debug(`[StateManager] Restored ${Object.keys(state).length} domain(s) from previous session`);
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (entryMode === 'resume') {
|
|
143
|
+
logger.warn('[StateManager] Entry mode is "resume" but no suspend_data found. This may indicate a previous session that was not properly saved.');
|
|
144
|
+
} else {
|
|
145
|
+
logger.debug('[StateManager] No suspend_data found, starting with fresh state');
|
|
146
|
+
}
|
|
147
|
+
return this.createFreshState();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validates loaded state against current course configuration and migrates if needed.
|
|
152
|
+
*/
|
|
153
|
+
validateAndMigrateState(loadedState) {
|
|
154
|
+
if (!this._validationConfig) {
|
|
155
|
+
logger.debug('[StateManager] No validation config set, skipping state validation');
|
|
156
|
+
if (!loadedState._meta) {
|
|
157
|
+
loadedState._meta = {
|
|
158
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
159
|
+
createdAt: new Date().toISOString()
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return loadedState;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { slideIds, objectiveIds: _objectiveIds } = this._validationConfig;
|
|
166
|
+
const storedSchemaVersion = loadedState._meta?.schemaVersion || 0;
|
|
167
|
+
|
|
168
|
+
logger.debug(`[StateManager] Validating state: stored schema v${storedSchemaVersion}, current v${STATE_SCHEMA_VERSION}`);
|
|
169
|
+
|
|
170
|
+
if (storedSchemaVersion > STATE_SCHEMA_VERSION) {
|
|
171
|
+
return handleStateMismatch(
|
|
172
|
+
'_meta',
|
|
173
|
+
`Stored state has newer schema version (${storedSchemaVersion}) than current (${STATE_SCHEMA_VERSION}). ` +
|
|
174
|
+
'This may indicate the course was downgraded.',
|
|
175
|
+
{ storedSchemaVersion, currentSchemaVersion: STATE_SCHEMA_VERSION },
|
|
176
|
+
this.createFreshState()
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (storedSchemaVersion < STATE_SCHEMA_VERSION) {
|
|
181
|
+
logger.info(`[StateManager] Upgrading state from schema v${storedSchemaVersion} to v${STATE_SCHEMA_VERSION}`);
|
|
182
|
+
loadedState = this._runMigrations(loadedState, storedSchemaVersion, STATE_SCHEMA_VERSION);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const validatedState = { ...loadedState };
|
|
186
|
+
|
|
187
|
+
validatedState._meta = {
|
|
188
|
+
...loadedState._meta,
|
|
189
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
190
|
+
lastValidatedAt: new Date().toISOString()
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (loadedState.navigation) {
|
|
194
|
+
validatedState.navigation = this._validateNavigationState(loadedState.navigation, slideIds);
|
|
195
|
+
}
|
|
196
|
+
if (loadedState.engagement) {
|
|
197
|
+
validatedState.engagement = this._validateEngagementState(loadedState.engagement, slideIds);
|
|
198
|
+
}
|
|
199
|
+
if (loadedState.interactionResponses) {
|
|
200
|
+
validatedState.interactionResponses = this._validateInteractionResponsesState(loadedState.interactionResponses);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const key of Object.keys(loadedState)) {
|
|
204
|
+
if (key.startsWith('assessment_')) {
|
|
205
|
+
const assessmentId = key.replace('assessment_', '');
|
|
206
|
+
validatedState[key] = this._validateAssessmentState(loadedState[key], assessmentId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return validatedState;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_runMigrations(state, fromVersion, toVersion) {
|
|
214
|
+
let migratedState = { ...state };
|
|
215
|
+
|
|
216
|
+
for (let version = fromVersion + 1; version <= toVersion; version++) {
|
|
217
|
+
const migration = STATE_MIGRATIONS[version];
|
|
218
|
+
if (migration) {
|
|
219
|
+
logger.info(`[StateManager] Running migration to schema v${version}`);
|
|
220
|
+
try {
|
|
221
|
+
migratedState = migration(migratedState);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const errorMessage = `State migration to v${version} failed: ${error.message}`;
|
|
224
|
+
logger.error(`[StateManager] ${errorMessage}`, error);
|
|
225
|
+
|
|
226
|
+
if (import.meta.env.DEV) {
|
|
227
|
+
throw new Error(errorMessage);
|
|
228
|
+
}
|
|
229
|
+
eventBus.emit('state:recovered', {
|
|
230
|
+
domain: '_meta',
|
|
231
|
+
message: errorMessage,
|
|
232
|
+
context: { fromVersion, toVersion, failedAtVersion: version },
|
|
233
|
+
action: 'migration_skipped'
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
logger.debug(`[StateManager] No migration defined for v${version}, skipping`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return migratedState;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_validateNavigationState(navState, slideIds) {
|
|
245
|
+
if (!navState || typeof navState !== 'object') return navState;
|
|
246
|
+
|
|
247
|
+
const validated = { ...navState };
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(navState.visitedSlides)) {
|
|
250
|
+
const invalidSlides = navState.visitedSlides.filter(id => !slideIds.has(id));
|
|
251
|
+
if (invalidSlides.length > 0) {
|
|
252
|
+
if (import.meta.env.DEV) {
|
|
253
|
+
logger.warn(
|
|
254
|
+
`[StateManager] Navigation state contains ${invalidSlides.length} invalid slide ID(s): ${invalidSlides.join(', ')}. ` +
|
|
255
|
+
'These slides no longer exist in the course structure and will be removed.'
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
validated.visitedSlides = navState.visitedSlides.filter(id => slideIds.has(id));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return validated;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_validateEngagementState(engagementState, slideIds) {
|
|
266
|
+
if (!engagementState || typeof engagementState !== 'object') return engagementState;
|
|
267
|
+
|
|
268
|
+
const validated = {};
|
|
269
|
+
const invalidSlides = [];
|
|
270
|
+
|
|
271
|
+
for (const [slideId, slideState] of Object.entries(engagementState)) {
|
|
272
|
+
if (slideIds.has(slideId)) {
|
|
273
|
+
validated[slideId] = slideState;
|
|
274
|
+
} else {
|
|
275
|
+
invalidSlides.push(slideId);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (invalidSlides.length > 0 && import.meta.env.DEV) {
|
|
280
|
+
logger.warn(
|
|
281
|
+
`[StateManager] Engagement state contains ${invalidSlides.length} invalid slide ID(s): ${invalidSlides.join(', ')}. ` +
|
|
282
|
+
'These slides no longer exist and their engagement data will be discarded.'
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return validated;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_validateInteractionResponsesState(responsesState) {
|
|
290
|
+
if (!responsesState || typeof responsesState !== 'object') return responsesState;
|
|
291
|
+
|
|
292
|
+
const validated = {};
|
|
293
|
+
for (const [id, state] of Object.entries(responsesState)) {
|
|
294
|
+
if (state && typeof state === 'object') {
|
|
295
|
+
validated[id] = state;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return validated;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_validateAssessmentState(assessmentState, assessmentId) {
|
|
302
|
+
if (!assessmentState || typeof assessmentState !== 'object') return assessmentState;
|
|
303
|
+
|
|
304
|
+
const validated = { ...assessmentState };
|
|
305
|
+
if (validated.session?.responses && typeof validated.session.responses !== 'object') {
|
|
306
|
+
logger.warn(`[StateManager] Assessment "${assessmentId}" has invalid response format. Clearing responses.`);
|
|
307
|
+
validated.session.responses = {};
|
|
308
|
+
}
|
|
309
|
+
return validated;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file transaction-log.js
|
|
3
|
+
* @description Ring buffer for recording state transactions. Used for debugging.
|
|
4
|
+
* @internal Only used by state-manager.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SIZE = 50;
|
|
8
|
+
|
|
9
|
+
export class TransactionLog {
|
|
10
|
+
constructor(size = DEFAULT_SIZE) {
|
|
11
|
+
this._buffer = new Array(size);
|
|
12
|
+
this._size = size;
|
|
13
|
+
this._head = 0;
|
|
14
|
+
this._count = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
record(domain, action, meta = {}) {
|
|
18
|
+
this._buffer[this._head] = {
|
|
19
|
+
domain,
|
|
20
|
+
action,
|
|
21
|
+
timestamp: Date.now(),
|
|
22
|
+
...meta
|
|
23
|
+
};
|
|
24
|
+
this._head = (this._head + 1) % this._size;
|
|
25
|
+
if (this._count < this._size) this._count++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getRecent(n = 10) {
|
|
29
|
+
const count = Math.min(n, this._count);
|
|
30
|
+
const entries = [];
|
|
31
|
+
for (let i = 0; i < count; i++) {
|
|
32
|
+
const idx = (this._head - 1 - i + this._size) % this._size;
|
|
33
|
+
entries.push(this._buffer[idx]);
|
|
34
|
+
}
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toArray() {
|
|
39
|
+
return this.getRecent(this._count);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file xapi-statement-service.js
|
|
3
|
+
* @description Format-agnostic event listener that bridges manager events to driver xAPI methods.
|
|
4
|
+
*
|
|
5
|
+
* This service subscribes to objective, interaction, and assessment events and routes them
|
|
6
|
+
* to the active driver's xAPI statement methods (if available). SCORM drivers don't implement
|
|
7
|
+
* these methods, so this is a no-op for SCORM formats.
|
|
8
|
+
*
|
|
9
|
+
* This enables rich xAPI learning records for cmi5 without modifying SCORM behavior.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { eventBus } from '../core/event-bus.js';
|
|
13
|
+
import { logger } from '../utilities/logger.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* xAPI Statement Service
|
|
17
|
+
* Bridges manager events to driver xAPI methods for cmi5 learning records.
|
|
18
|
+
*/
|
|
19
|
+
class XapiStatementService {
|
|
20
|
+
constructor() {
|
|
21
|
+
this._driver = null;
|
|
22
|
+
this._isInitialized = false;
|
|
23
|
+
this._subscriptions = [];
|
|
24
|
+
|
|
25
|
+
// Slide time tracking
|
|
26
|
+
this._currentSlideId = null;
|
|
27
|
+
this._currentSlideTitle = null;
|
|
28
|
+
this._slideEntryTime = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Initializes the service with the active driver.
|
|
33
|
+
* Only subscribed events if the driver supports xAPI methods.
|
|
34
|
+
* @param {Object} driver - The active LMS driver instance
|
|
35
|
+
*/
|
|
36
|
+
initialize(driver) {
|
|
37
|
+
if (this._isInitialized) {
|
|
38
|
+
logger.warn('[XapiStatementService] Already initialized');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this._driver = driver;
|
|
43
|
+
|
|
44
|
+
// Only subscribe if driver has xAPI methods (cmi5 only)
|
|
45
|
+
if (!this._hasXapiSupport()) {
|
|
46
|
+
logger.debug('[XapiStatementService] Driver does not support xAPI statements, service inactive');
|
|
47
|
+
this._isInitialized = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger.info('[XapiStatementService] Initializing xAPI statement bridge');
|
|
52
|
+
this._subscribeToEvents();
|
|
53
|
+
this._isInitialized = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if the driver supports xAPI statement methods.
|
|
58
|
+
* @private
|
|
59
|
+
*/
|
|
60
|
+
_hasXapiSupport() {
|
|
61
|
+
return (
|
|
62
|
+
this._driver &&
|
|
63
|
+
typeof this._driver.sendObjectiveStatement === 'function' &&
|
|
64
|
+
typeof this._driver.sendInteractionStatement === 'function' &&
|
|
65
|
+
typeof this._driver.sendAssessmentStatement === 'function' &&
|
|
66
|
+
typeof this._driver.sendSlideStatement === 'function'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Subscribes to manager events and routes to xAPI methods.
|
|
72
|
+
* @private
|
|
73
|
+
*/
|
|
74
|
+
_subscribeToEvents() {
|
|
75
|
+
// Objective events
|
|
76
|
+
this._subscriptions.push(
|
|
77
|
+
eventBus.on('objective:updated', this._handleObjectiveUpdated.bind(this))
|
|
78
|
+
);
|
|
79
|
+
this._subscriptions.push(
|
|
80
|
+
eventBus.on('objective:score:updated', this._handleObjectiveScoreUpdated.bind(this))
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Interaction events
|
|
84
|
+
this._subscriptions.push(
|
|
85
|
+
eventBus.on('interaction:recorded', this._handleInteractionRecorded.bind(this))
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Assessment events
|
|
89
|
+
this._subscriptions.push(
|
|
90
|
+
eventBus.on('assessment:submitted', this._handleAssessmentSubmitted.bind(this))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Navigation events for slide tracking
|
|
94
|
+
this._subscriptions.push(
|
|
95
|
+
eventBus.on('navigation:changed', this._handleNavigationChanged.bind(this))
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Session termination - send pending slide statement before LRS connection closes
|
|
99
|
+
this._subscriptions.push(
|
|
100
|
+
eventBus.on('session:beforeTerminate', this._handleBeforeTerminate.bind(this))
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
logger.debug('[XapiStatementService] Subscribed to manager events');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handles objective update events.
|
|
108
|
+
* @private
|
|
109
|
+
*/
|
|
110
|
+
async _handleObjectiveUpdated(objective) {
|
|
111
|
+
if (!objective?.id) return;
|
|
112
|
+
|
|
113
|
+
// Determine verb based on status changes
|
|
114
|
+
let verb = 'progressed';
|
|
115
|
+
if (objective.completion_status === 'completed') {
|
|
116
|
+
verb = 'completed';
|
|
117
|
+
}
|
|
118
|
+
if (objective.success_status === 'passed') {
|
|
119
|
+
verb = 'passed';
|
|
120
|
+
} else if (objective.success_status === 'failed') {
|
|
121
|
+
verb = 'failed';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await this._driver.sendObjectiveStatement({
|
|
126
|
+
id: objective.id,
|
|
127
|
+
verb: verb,
|
|
128
|
+
name: objective.description || objective.id,
|
|
129
|
+
score: objective.score !== undefined ? objective.score / 100 : undefined
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
logger.error('[XapiStatementService] Failed to send objective statement:', error);
|
|
133
|
+
// Don't throw - xAPI statements are non-blocking
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Handles objective score update events.
|
|
139
|
+
* @private
|
|
140
|
+
*/
|
|
141
|
+
async _handleObjectiveScoreUpdated({ id, objectiveId, score }) {
|
|
142
|
+
const resolvedId = id || objectiveId;
|
|
143
|
+
if (!resolvedId) return;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await this._driver.sendObjectiveStatement({
|
|
147
|
+
id: resolvedId,
|
|
148
|
+
verb: 'progressed',
|
|
149
|
+
score: typeof score === 'number' && !isNaN(score) ? score / 100 : undefined
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.error('[XapiStatementService] Failed to send objective score statement:', error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Handles interaction recorded events.
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
async _handleInteractionRecorded(interaction) {
|
|
161
|
+
if (!interaction?.id) return;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await this._driver.sendInteractionStatement({
|
|
165
|
+
id: interaction.id,
|
|
166
|
+
type: interaction.type || 'other',
|
|
167
|
+
response: interaction.learner_response || '',
|
|
168
|
+
correct: interaction.result === 'correct',
|
|
169
|
+
description: interaction.description || undefined,
|
|
170
|
+
duration: interaction.latency || undefined,
|
|
171
|
+
objectiveId: interaction.objectives?.[0] || undefined
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.error('[XapiStatementService] Failed to send interaction statement:', error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Handles assessment submitted events.
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
async _handleAssessmentSubmitted({ assessmentId, results }) {
|
|
183
|
+
if (!assessmentId || !results) return;
|
|
184
|
+
|
|
185
|
+
// Calculate ISO 8601 duration from timeSpent (MM:SS format)
|
|
186
|
+
let duration;
|
|
187
|
+
if (results.timeSpent) {
|
|
188
|
+
const parts = results.timeSpent.split(':');
|
|
189
|
+
if (parts.length === 3) {
|
|
190
|
+
const hours = parseInt(parts[0], 10);
|
|
191
|
+
const minutes = parseInt(parts[1], 10);
|
|
192
|
+
const seconds = parseInt(parts[2], 10);
|
|
193
|
+
if (!isNaN(hours) && !isNaN(minutes) && !isNaN(seconds)) {
|
|
194
|
+
duration = `PT${hours}H${minutes}M${seconds}S`;
|
|
195
|
+
}
|
|
196
|
+
} else if (parts.length === 2) {
|
|
197
|
+
const minutes = parseInt(parts[0], 10);
|
|
198
|
+
const seconds = parseInt(parts[1], 10);
|
|
199
|
+
if (!isNaN(minutes) && !isNaN(seconds)) {
|
|
200
|
+
duration = `PT${minutes}M${seconds}S`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const score = typeof results.scorePercentage === 'number' ? results.scorePercentage / 100 : undefined;
|
|
207
|
+
await this._driver.sendAssessmentStatement({
|
|
208
|
+
id: assessmentId,
|
|
209
|
+
score, // Convert to 0-1 scaled; undefined if not provided
|
|
210
|
+
passed: results.passed,
|
|
211
|
+
questionCount: results.totalQuestions,
|
|
212
|
+
correctCount: results.correctCount,
|
|
213
|
+
attemptNumber: results.attemptNumber,
|
|
214
|
+
duration: duration
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
logger.error('[XapiStatementService] Failed to send assessment statement:', error);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Handles navigation change events for slide tracking.
|
|
223
|
+
* Sends 'experienced' statement for the previous slide with duration.
|
|
224
|
+
* @private
|
|
225
|
+
*/
|
|
226
|
+
async _handleNavigationChanged({ fromSlideId, toSlideId, slideTitle }) {
|
|
227
|
+
// Send statement for previous slide (if any)
|
|
228
|
+
if (fromSlideId && this._slideEntryTime) {
|
|
229
|
+
const duration = this._calculateDuration(this._slideEntryTime);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await this._driver.sendSlideStatement({
|
|
233
|
+
id: fromSlideId,
|
|
234
|
+
title: this._currentSlideTitle || undefined,
|
|
235
|
+
verb: 'experienced',
|
|
236
|
+
duration: duration
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
logger.error('[XapiStatementService] Failed to send slide statement:', error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Track entry time and title for new slide
|
|
244
|
+
this._currentSlideId = toSlideId;
|
|
245
|
+
this._currentSlideTitle = slideTitle || null;
|
|
246
|
+
this._slideEntryTime = Date.now();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Calculates ISO 8601 duration from a start timestamp.
|
|
251
|
+
* @private
|
|
252
|
+
*/
|
|
253
|
+
_calculateDuration(startTime) {
|
|
254
|
+
const elapsedMs = Date.now() - startTime;
|
|
255
|
+
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
256
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
257
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
258
|
+
const seconds = totalSeconds % 60;
|
|
259
|
+
|
|
260
|
+
if (hours > 0) {
|
|
261
|
+
return `PT${hours}H${minutes}M${seconds}S`;
|
|
262
|
+
} else if (minutes > 0) {
|
|
263
|
+
return `PT${minutes}M${seconds}S`;
|
|
264
|
+
} else {
|
|
265
|
+
return `PT${seconds}S`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Handles session termination by sending the final slide statement.
|
|
271
|
+
* @private
|
|
272
|
+
*/
|
|
273
|
+
async _handleBeforeTerminate() {
|
|
274
|
+
await this._sendPendingSlideStatement();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Sends an 'experienced' statement for the current slide if one is pending.
|
|
279
|
+
* This captures time spent on the final slide before session ends.
|
|
280
|
+
* @private
|
|
281
|
+
*/
|
|
282
|
+
async _sendPendingSlideStatement() {
|
|
283
|
+
if (!this._currentSlideId || !this._slideEntryTime || !this._driver) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const duration = this._calculateDuration(this._slideEntryTime);
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
await this._driver.sendSlideStatement({
|
|
291
|
+
id: this._currentSlideId,
|
|
292
|
+
title: this._currentSlideTitle || undefined,
|
|
293
|
+
verb: 'experienced',
|
|
294
|
+
duration: duration
|
|
295
|
+
});
|
|
296
|
+
logger.debug(`[XapiStatementService] Sent final slide statement: ${this._currentSlideId}`);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
logger.error('[XapiStatementService] Failed to send final slide statement:', error);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Clear tracking to prevent duplicate sends
|
|
302
|
+
this._currentSlideId = null;
|
|
303
|
+
this._currentSlideTitle = null;
|
|
304
|
+
this._slideEntryTime = null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Cleans up event subscriptions.
|
|
309
|
+
*/
|
|
310
|
+
destroy() {
|
|
311
|
+
this._subscriptions.forEach(unsub => {
|
|
312
|
+
if (typeof unsub === 'function') unsub();
|
|
313
|
+
});
|
|
314
|
+
this._subscriptions = [];
|
|
315
|
+
this._driver = null;
|
|
316
|
+
this._isInitialized = false;
|
|
317
|
+
this._currentSlideId = null;
|
|
318
|
+
this._currentSlideTitle = null;
|
|
319
|
+
this._slideEntryTime = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Singleton instance
|
|
324
|
+
const xapiStatementService = new XapiStatementService();
|
|
325
|
+
export default xapiStatementService;
|