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,420 @@
|
|
|
1
|
+
import { deepClone, deepMerge } from './utilities.js';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
|
|
4
|
+
const SLIDE_ALIAS_PREFIX = '@slides/';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Vite expands glob imports into an object literal where each key is a file path
|
|
8
|
+
* and the value is a function that lazy-loads the module. Building a registry up front
|
|
9
|
+
* ensures production builds have static references to every slide file.
|
|
10
|
+
*
|
|
11
|
+
* Uses the @slides alias which is resolved by each vite config:
|
|
12
|
+
* - Production courses (vite.config.js): @slides -> course/slides
|
|
13
|
+
* - Framework dev (vite.framework-dev.config.js): @slides -> template/course/slides
|
|
14
|
+
*/
|
|
15
|
+
const slideModules = import.meta.glob('@slides/**/*.js');
|
|
16
|
+
const slideModuleRegistry = new Map();
|
|
17
|
+
|
|
18
|
+
for (const [globPath, loader] of Object.entries(slideModules)) {
|
|
19
|
+
// Normalize path to @slides/filename.js format
|
|
20
|
+
const aliasPath = globPath.startsWith('@slides/')
|
|
21
|
+
? globPath
|
|
22
|
+
: '@slides/' + globPath.split('/slides/').pop();
|
|
23
|
+
slideModuleRegistry.set(aliasPath, loader);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Loads a slide module dynamically.
|
|
28
|
+
* Note: ES modules are cached by the browser automatically, so we don't need our own cache.
|
|
29
|
+
* @param {string} path - The path to the slide module (e.g., '@slides/intro.js')
|
|
30
|
+
* @returns {Promise<object>} The loaded module
|
|
31
|
+
* @throws {Error} If the module cannot be loaded
|
|
32
|
+
*/
|
|
33
|
+
async function loadSlideModule(path) {
|
|
34
|
+
const normalizedPath = normalizeComponentPath(path);
|
|
35
|
+
const loader = slideModuleRegistry.get(normalizedPath);
|
|
36
|
+
|
|
37
|
+
if (!loader) {
|
|
38
|
+
throw new Error(`Failed to find slide module registered at ${normalizedPath} (original: ${path})`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return await loader();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.error(`Failed to load slide module for path: ${path}`, error);
|
|
45
|
+
throw new Error(`Failed to load slide module at ${path}: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let courseConfig;
|
|
50
|
+
let derivedDataCache = null;
|
|
51
|
+
|
|
52
|
+
function ensureArray(value) {
|
|
53
|
+
if (!value) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return Array.isArray(value) ? value : [value];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeControls(controls) {
|
|
60
|
+
if (!controls) {
|
|
61
|
+
throw new Error('normalizeControls: controls parameter is required');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
nextTarget: controls.nextTarget || null,
|
|
66
|
+
previousTarget: controls.previousTarget || null,
|
|
67
|
+
exitTarget: controls.exitTarget || null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeConditions(block) {
|
|
72
|
+
return ensureArray(block)
|
|
73
|
+
.map(condition => (condition && typeof condition === 'object' ? { ...condition } : null))
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeGating(gating) {
|
|
78
|
+
if (!gating) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!gating.conditions) {
|
|
83
|
+
throw new Error('Gating configuration must have a "conditions" array. Legacy "condition" property is not supported.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const normalizedConditions = normalizeConditions(gating.conditions);
|
|
87
|
+
if (!normalizedConditions.length) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
mode: gating.mode === 'any' ? 'any' : 'all',
|
|
93
|
+
message: gating.message || null,
|
|
94
|
+
conditions: normalizedConditions,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeSequence(sequence) {
|
|
99
|
+
if (!sequence) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const includeWhen = normalizeConditions(sequence.includeWhen);
|
|
104
|
+
const skipUntil = normalizeConditions(sequence.skipUntil);
|
|
105
|
+
const insert = sequence.insert && (sequence.insert.slideId || sequence.insert.anchor)
|
|
106
|
+
? {
|
|
107
|
+
position: sequence.insert.position === 'after' ? 'after' : 'before',
|
|
108
|
+
slideId: sequence.insert.slideId || sequence.insert.anchor,
|
|
109
|
+
}
|
|
110
|
+
: null;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
includeByDefault: sequence.includeByDefault !== false,
|
|
114
|
+
includeWhen,
|
|
115
|
+
skipUntil,
|
|
116
|
+
insert,
|
|
117
|
+
message: sequence.message || null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeNavigation(nodeNavigation, nodeType, assessmentId) {
|
|
122
|
+
if (!nodeNavigation) {
|
|
123
|
+
throw new Error('normalizeNavigation: nodeNavigation parameter is required');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const navigation = {
|
|
127
|
+
sequential: nodeNavigation.sequential !== false,
|
|
128
|
+
controls: normalizeControls(nodeNavigation.controls || {}),
|
|
129
|
+
gating: normalizeGating(nodeNavigation.gating),
|
|
130
|
+
sequence: normalizeSequence(nodeNavigation.sequence),
|
|
131
|
+
assessmentRef: nodeNavigation.assessmentRef || (nodeType === 'assessment' ? assessmentId : null),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return navigation;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getStructure() {
|
|
138
|
+
if (!courseConfig) {
|
|
139
|
+
throw new Error('CourseHelpers: courseConfig not initialized. Call init() first.');
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(courseConfig.structure)) {
|
|
142
|
+
throw new Error('CourseHelpers: courseConfig.structure must be an array.');
|
|
143
|
+
}
|
|
144
|
+
return courseConfig.structure;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Computes derived data from the course configuration.
|
|
149
|
+
* This function is called once per session and caches the result.
|
|
150
|
+
* @returns {Promise<object>} Derived data including slides, indexById, menuTree, and assessmentConfigs
|
|
151
|
+
*/
|
|
152
|
+
async function computeDerived() {
|
|
153
|
+
if (derivedDataCache) {
|
|
154
|
+
return derivedDataCache;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const slides = [];
|
|
158
|
+
const indexById = {};
|
|
159
|
+
const menuTree = [];
|
|
160
|
+
const assessmentConfigs = new Map(); // Registry for assessment configurations
|
|
161
|
+
|
|
162
|
+
async function walk(nodes, level = 0, targetMenu = menuTree) {
|
|
163
|
+
if (!Array.isArray(nodes)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const node of nodes) {
|
|
168
|
+
if (!node || typeof node !== 'object') {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (node.type === 'section') {
|
|
173
|
+
const childrenContainer = [];
|
|
174
|
+
await walk(node.children || [], level + 1, childrenContainer);
|
|
175
|
+
|
|
176
|
+
const isHidden = node.menu && node.menu.hidden;
|
|
177
|
+
if (isHidden) {
|
|
178
|
+
targetMenu.push(...childrenContainer);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sectionLabel = node.menu?.label || node.title || node.id || 'Section';
|
|
183
|
+
const sectionItem = {
|
|
184
|
+
type: 'section',
|
|
185
|
+
id: node.id || sectionLabel.toLowerCase().replace(/\s+/g, '-'),
|
|
186
|
+
label: sectionLabel,
|
|
187
|
+
icon: node.menu?.icon || null,
|
|
188
|
+
defaultExpanded: node.menu?.defaultExpanded !== false,
|
|
189
|
+
collapsible: node.menu?.collapsible !== false,
|
|
190
|
+
level,
|
|
191
|
+
children: childrenContainer,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
targetMenu.push(sectionItem);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const componentPath = node.component || node.slide || node.content || node.module;
|
|
199
|
+
if (typeof componentPath !== 'string') {
|
|
200
|
+
throw new Error(`Slide '${node.id || 'unknown'}' is missing a component path string.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const slideId = node.id;
|
|
204
|
+
if (!slideId) {
|
|
205
|
+
throw new Error('Slide is missing an id');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let component;
|
|
209
|
+
let assessmentId = null;
|
|
210
|
+
const kind = node.type === 'assessment' ? 'assessment' : 'slide';
|
|
211
|
+
|
|
212
|
+
const module = await loadSlideModule(componentPath);
|
|
213
|
+
if (kind === 'assessment') {
|
|
214
|
+
if (!module.config || !module.slide) {
|
|
215
|
+
throw new Error(`Assessment module at ${componentPath} must export named 'config' and 'slide' objects.`);
|
|
216
|
+
}
|
|
217
|
+
if (typeof module.slide.render !== 'function') {
|
|
218
|
+
throw new Error(`Assessment 'slide' export at ${componentPath} does not have a render function.`);
|
|
219
|
+
}
|
|
220
|
+
assessmentId = module.config.id;
|
|
221
|
+
component = module.slide;
|
|
222
|
+
// Register the assessment configuration
|
|
223
|
+
if (assessmentConfigs.has(assessmentId)) {
|
|
224
|
+
throw new Error(`[CourseHelpers] Duplicate assessment ID '${assessmentId}' found. Each assessment must have a unique ID in course-config.js.`);
|
|
225
|
+
}
|
|
226
|
+
assessmentConfigs.set(assessmentId, module.config);
|
|
227
|
+
} else {
|
|
228
|
+
// For regular slides, look for named 'slide' export first, then fall back to first export
|
|
229
|
+
component = module.slide || Object.values(module)[0];
|
|
230
|
+
if (!component || typeof component.render !== 'function') {
|
|
231
|
+
throw new Error(`Slide module at ${componentPath} does not have a valid component export with a render function.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const title = node.title || component.title || node.menu?.label || slideId;
|
|
236
|
+
const navigation = normalizeNavigation(node.navigation || {}, kind, assessmentId);
|
|
237
|
+
const menuConfig = node.menu || {};
|
|
238
|
+
const slideIndex = slides.length;
|
|
239
|
+
|
|
240
|
+
const slideEntry = {
|
|
241
|
+
type: kind,
|
|
242
|
+
id: slideId,
|
|
243
|
+
assessmentId,
|
|
244
|
+
index: slideIndex,
|
|
245
|
+
component,
|
|
246
|
+
title,
|
|
247
|
+
audio: node.audio, // Pass through audio config from structure
|
|
248
|
+
engagement: node.engagement, // Pass through engagement config from structure
|
|
249
|
+
navigation,
|
|
250
|
+
menu: menuConfig,
|
|
251
|
+
metadata: node.metadata || {},
|
|
252
|
+
assessment: kind === 'assessment' ? (node.assessment || {}) : null,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
slides.push(slideEntry);
|
|
256
|
+
indexById[slideId] = slideIndex;
|
|
257
|
+
|
|
258
|
+
if (!menuConfig.hidden) {
|
|
259
|
+
const menuItem = {
|
|
260
|
+
type: 'slide',
|
|
261
|
+
id: slideId,
|
|
262
|
+
label: menuConfig.label || title,
|
|
263
|
+
icon: menuConfig.icon || null,
|
|
264
|
+
level,
|
|
265
|
+
slideIndex,
|
|
266
|
+
};
|
|
267
|
+
targetMenu.push(menuItem);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await walk(getStructure(), 0, menuTree);
|
|
273
|
+
|
|
274
|
+
derivedDataCache = { slides, indexById, menuTree, assessmentConfigs };
|
|
275
|
+
return derivedDataCache;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cloneSlide(entry) {
|
|
279
|
+
if (!entry) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
...entry,
|
|
284
|
+
navigation: deepClone(entry.navigation),
|
|
285
|
+
menu: deepClone(entry.menu),
|
|
286
|
+
metadata: deepClone(entry.metadata),
|
|
287
|
+
assessment: deepClone(entry.assessment),
|
|
288
|
+
audio: deepClone(entry.audio),
|
|
289
|
+
engagement: deepClone(entry.engagement),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Initializes CourseHelpers with the course configuration.
|
|
295
|
+
* The config is frozen to prevent accidental mutations.
|
|
296
|
+
* @param {object} config - The course configuration object
|
|
297
|
+
* @throws {Error} If already initialized
|
|
298
|
+
*/
|
|
299
|
+
export function init(config) {
|
|
300
|
+
if (courseConfig) {
|
|
301
|
+
throw new Error('CourseHelpers: Already initialized. Do not call init() more than once.');
|
|
302
|
+
}
|
|
303
|
+
if (!config) {
|
|
304
|
+
throw new Error('CourseHelpers: config parameter is required.');
|
|
305
|
+
}
|
|
306
|
+
// Freeze the config to make it immutable
|
|
307
|
+
courseConfig = Object.freeze(config);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function getFlattenedSlides() {
|
|
311
|
+
const data = await computeDerived();
|
|
312
|
+
return data.slides.map(slide => cloneSlide(slide));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function getSlideEntry(index) {
|
|
316
|
+
const data = await computeDerived();
|
|
317
|
+
return cloneSlide(data.slides[index] || null);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function getSlideComponent(index) {
|
|
321
|
+
const entry = await getSlideEntry(index);
|
|
322
|
+
if (!entry) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
// The component is already the correct 'slide' object from the module
|
|
326
|
+
return entry.component;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function countSlides() {
|
|
330
|
+
const data = await computeDerived();
|
|
331
|
+
return data.slides.length;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function getSlideIndex(slideId) {
|
|
335
|
+
if (!slideId) return null;
|
|
336
|
+
const data = await computeDerived();
|
|
337
|
+
const index = data.indexById[slideId];
|
|
338
|
+
return index === undefined ? null : index;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function getSlideById(slideId) {
|
|
342
|
+
const index = await getSlideIndex(slideId);
|
|
343
|
+
if (index === null) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return getSlideEntry(index);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function getSlideName(index) {
|
|
350
|
+
const entry = await getSlideEntry(index);
|
|
351
|
+
if (entry) {
|
|
352
|
+
return entry.title || `Section ${index + 1}`;
|
|
353
|
+
}
|
|
354
|
+
return `Section ${index + 1}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function getMenuTree() {
|
|
358
|
+
const data = await computeDerived();
|
|
359
|
+
return deepClone(data.menuTree);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function isSequentiallyEnabled(index) {
|
|
363
|
+
const entry = await getSlideEntry(index);
|
|
364
|
+
if (!entry) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
return entry.navigation?.sequential !== false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function getAssessmentConfig(slideIndex, overrides = {}) {
|
|
371
|
+
const entry = await getSlideEntry(slideIndex);
|
|
372
|
+
if (!entry || entry.type !== 'assessment') {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const merged = deepMerge(entry.assessment || {}, overrides);
|
|
376
|
+
return deepClone(merged);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Gets all slides of a specific type from the course structure.
|
|
381
|
+
* @param {string} type - The type of slide to filter for (e.g., 'assessment')
|
|
382
|
+
* @returns {Promise<Array>} Array of slides matching the specified type
|
|
383
|
+
*/
|
|
384
|
+
export async function getSlidesByType(type) {
|
|
385
|
+
const data = await computeDerived();
|
|
386
|
+
return data.slides.filter(slide => slide.type === type).map(slide => deepClone(slide));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Returns the registry of all assessment configurations, keyed by assessment ID.
|
|
391
|
+
* @returns {Promise<Map>} A map of assessment configuration objects.
|
|
392
|
+
*/
|
|
393
|
+
export async function getAssessmentConfigs() {
|
|
394
|
+
const data = await computeDerived();
|
|
395
|
+
return data.assessmentConfigs;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function normalizeComponentPath(path) {
|
|
399
|
+
if (!path || typeof path !== 'string') {
|
|
400
|
+
throw new Error('Slide module path must be a non-empty string.');
|
|
401
|
+
}
|
|
402
|
+
const normalized = path.replace(/\\/g, '/');
|
|
403
|
+
if (normalized.startsWith(SLIDE_ALIAS_PREFIX)) {
|
|
404
|
+
return normalized;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const trimmed = normalized.replace(/^\.\//, '');
|
|
408
|
+
if (trimmed.startsWith('course/slides/')) {
|
|
409
|
+
return `${SLIDE_ALIAS_PREFIX}${trimmed.slice('course/slides/'.length)}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const courseIndex = trimmed.indexOf('/course/slides/');
|
|
413
|
+
if (courseIndex !== -1) {
|
|
414
|
+
return `${SLIDE_ALIAS_PREFIX}${trimmed.slice(courseIndex + '/course/slides/'.length)}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return normalized;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Reporter - Optional external learning data reporting via webhook
|
|
3
|
+
*
|
|
4
|
+
* Batches important learning records (assessments, objectives, interactions) and
|
|
5
|
+
* sends them to a configured endpoint. Works across all LMS formats.
|
|
6
|
+
*
|
|
7
|
+
* Configuration in course-config.js:
|
|
8
|
+
* environment: {
|
|
9
|
+
* dataReporting: {
|
|
10
|
+
* endpoint: 'https://your-endpoint.workers.dev/data',
|
|
11
|
+
* batchSize: 10, // Flush after N records (default: 10)
|
|
12
|
+
* flushInterval: 30000, // Or flush every N ms (default: 30000)
|
|
13
|
+
* includeContext: true // Include course metadata (default: true)
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { eventBus } from '../core/event-bus.js';
|
|
19
|
+
import { logger } from './logger.js';
|
|
20
|
+
|
|
21
|
+
// Batching state
|
|
22
|
+
let batch = [];
|
|
23
|
+
let flushTimer = null;
|
|
24
|
+
|
|
25
|
+
// Configuration
|
|
26
|
+
let _config = null;
|
|
27
|
+
let _courseConfig = null;
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
30
|
+
const DEFAULT_FLUSH_INTERVAL = 30000; // 30 seconds
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Queue a record for batched sending
|
|
34
|
+
* @param {string} type - Record type: 'assessment', 'objective', 'interaction', 'session'
|
|
35
|
+
* @param {Object} data - Record data
|
|
36
|
+
*/
|
|
37
|
+
function queueRecord(type, data) {
|
|
38
|
+
if (!_config?.endpoint) return;
|
|
39
|
+
|
|
40
|
+
const record = {
|
|
41
|
+
type,
|
|
42
|
+
data,
|
|
43
|
+
timestamp: new Date().toISOString()
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
batch.push(record);
|
|
47
|
+
logger.debug(`[DataReporter] Queued ${type} record (${batch.length} in batch)`);
|
|
48
|
+
|
|
49
|
+
const batchSize = _config.batchSize || DEFAULT_BATCH_SIZE;
|
|
50
|
+
|
|
51
|
+
// Flush if batch size reached
|
|
52
|
+
if (batch.length >= batchSize) {
|
|
53
|
+
flush();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Schedule flush on timer if not already scheduled
|
|
58
|
+
if (!flushTimer) {
|
|
59
|
+
const interval = _config.flushInterval || DEFAULT_FLUSH_INTERVAL;
|
|
60
|
+
flushTimer = setTimeout(() => {
|
|
61
|
+
flush();
|
|
62
|
+
}, interval);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Flush the current batch to the endpoint
|
|
68
|
+
*/
|
|
69
|
+
async function flush() {
|
|
70
|
+
if (batch.length === 0) return;
|
|
71
|
+
|
|
72
|
+
// Clear timer
|
|
73
|
+
if (flushTimer) {
|
|
74
|
+
clearTimeout(flushTimer);
|
|
75
|
+
flushTimer = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Take all records, empty batch
|
|
79
|
+
const records = batch.splice(0);
|
|
80
|
+
const payload = buildPayload(records);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
84
|
+
if (_config.apiKey) headers['Authorization'] = `Bearer ${_config.apiKey}`;
|
|
85
|
+
|
|
86
|
+
const response = await fetch(_config.endpoint, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers,
|
|
89
|
+
body: JSON.stringify(payload)
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (response.ok) {
|
|
93
|
+
logger.debug(`[DataReporter] Batch sent successfully (${records.length} records)`);
|
|
94
|
+
} else {
|
|
95
|
+
logger.warn(`[DataReporter] Failed to send batch: ${response.status}`);
|
|
96
|
+
// Re-queue failed records at front of batch
|
|
97
|
+
batch.unshift(...records);
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
logger.warn(`[DataReporter] Network error sending batch: ${e.message}`);
|
|
101
|
+
// Re-queue failed records
|
|
102
|
+
batch.unshift(...records);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Emergency flush using sendBeacon (for page unload)
|
|
108
|
+
*/
|
|
109
|
+
function emergencyFlush() {
|
|
110
|
+
if (batch.length === 0 || !_config?.endpoint) return;
|
|
111
|
+
|
|
112
|
+
const records = batch.splice(0);
|
|
113
|
+
const payload = buildPayload(records);
|
|
114
|
+
const body = JSON.stringify(payload);
|
|
115
|
+
|
|
116
|
+
// sendBeacon doesn't support custom headers, so when apiKey is configured
|
|
117
|
+
// we use fetch with keepalive (unload-safe) to include the auth header
|
|
118
|
+
if (_config.apiKey) {
|
|
119
|
+
try {
|
|
120
|
+
fetch(_config.endpoint, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
'Authorization': `Bearer ${_config.apiKey}`
|
|
125
|
+
},
|
|
126
|
+
body,
|
|
127
|
+
keepalive: true
|
|
128
|
+
});
|
|
129
|
+
logger.debug(`[DataReporter] Emergency flush: ${records.length} records sent via fetch+keepalive`);
|
|
130
|
+
} catch {
|
|
131
|
+
logger.warn('[DataReporter] Emergency flush: fetch+keepalive failed');
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
const blob = new Blob([body], { type: 'application/json' });
|
|
135
|
+
const sent = navigator.sendBeacon(_config.endpoint, blob);
|
|
136
|
+
if (sent) {
|
|
137
|
+
logger.debug(`[DataReporter] Emergency flush: ${records.length} records sent via sendBeacon`);
|
|
138
|
+
} else {
|
|
139
|
+
logger.warn('[DataReporter] Emergency flush: sendBeacon failed');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the payload with optional course context
|
|
146
|
+
*/
|
|
147
|
+
function buildPayload(records) {
|
|
148
|
+
const payload = {
|
|
149
|
+
records,
|
|
150
|
+
sentAt: new Date().toISOString()
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (_config.includeContext !== false && _courseConfig) {
|
|
154
|
+
payload.course = {
|
|
155
|
+
title: _courseConfig.metadata?.title,
|
|
156
|
+
version: _courseConfig.metadata?.version,
|
|
157
|
+
id: _courseConfig.metadata?.id
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return payload;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =============================================================================
|
|
165
|
+
// Event Handlers
|
|
166
|
+
// =============================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Handle assessment submission
|
|
170
|
+
*/
|
|
171
|
+
function handleAssessmentSubmitted({ assessmentId, results }) {
|
|
172
|
+
if (!assessmentId || !results) return;
|
|
173
|
+
|
|
174
|
+
queueRecord('assessment', {
|
|
175
|
+
assessmentId,
|
|
176
|
+
score: results.scorePercentage,
|
|
177
|
+
passed: results.passed,
|
|
178
|
+
attemptNumber: results.attemptNumber,
|
|
179
|
+
totalQuestions: results.totalQuestions,
|
|
180
|
+
correctCount: results.correctCount,
|
|
181
|
+
timeSpent: results.timeSpent
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handle objective updates - only report meaningful changes
|
|
187
|
+
*/
|
|
188
|
+
function handleObjectiveUpdated(objective) {
|
|
189
|
+
if (!objective?.id) return;
|
|
190
|
+
|
|
191
|
+
// Only report completed objectives or pass/fail status changes
|
|
192
|
+
const isComplete = objective.completion_status === 'completed';
|
|
193
|
+
const hasFinalResult = objective.success_status === 'passed' || objective.success_status === 'failed';
|
|
194
|
+
|
|
195
|
+
if (!isComplete && !hasFinalResult) {
|
|
196
|
+
return; // Skip intermediate updates
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
queueRecord('objective', {
|
|
200
|
+
objectiveId: objective.id,
|
|
201
|
+
completion_status: objective.completion_status,
|
|
202
|
+
success_status: objective.success_status,
|
|
203
|
+
score: objective.score
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Handle interaction recorded (submitted answers only)
|
|
209
|
+
*/
|
|
210
|
+
function handleInteractionRecorded(interaction) {
|
|
211
|
+
if (!interaction?.id) return;
|
|
212
|
+
|
|
213
|
+
queueRecord('interaction', {
|
|
214
|
+
interactionId: interaction.id,
|
|
215
|
+
type: interaction.type,
|
|
216
|
+
result: interaction.result,
|
|
217
|
+
learner_response: interaction.learner_response
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Handle session termination - flush remaining records
|
|
223
|
+
*/
|
|
224
|
+
function handleBeforeTerminate() {
|
|
225
|
+
// Attempt async flush first
|
|
226
|
+
flush().catch(() => {
|
|
227
|
+
// Fall back to emergency sendBeacon
|
|
228
|
+
emergencyFlush();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// =============================================================================
|
|
233
|
+
// Initialization
|
|
234
|
+
// =============================================================================
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Initialize data reporting if configured
|
|
238
|
+
* @param {Object} courseConfig - The course configuration object
|
|
239
|
+
*/
|
|
240
|
+
export function initDataReporter(courseConfig) {
|
|
241
|
+
const config = courseConfig.environment?.dataReporting;
|
|
242
|
+
|
|
243
|
+
_config = config;
|
|
244
|
+
_courseConfig = courseConfig;
|
|
245
|
+
|
|
246
|
+
// Never send data during local dev — the preview server and dev command
|
|
247
|
+
// inject VITE_COURSECODE_LOCAL into the Vite build env, which is auto-exposed
|
|
248
|
+
// to client code. Production builds via `coursecode build` don't set this.
|
|
249
|
+
if (import.meta.env.VITE_COURSECODE_LOCAL) {
|
|
250
|
+
logger.debug('[DataReporter] Disabled in local dev mode');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Disabled if no endpoint configured
|
|
255
|
+
if (!config?.endpoint) {
|
|
256
|
+
logger.debug('[DataReporter] Not configured, skipping initialization');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
logger.info('[DataReporter] Initialized with endpoint:', config.endpoint);
|
|
261
|
+
|
|
262
|
+
// Subscribe to important events
|
|
263
|
+
eventBus.on('assessment:submitted', handleAssessmentSubmitted);
|
|
264
|
+
eventBus.on('objective:updated', handleObjectiveUpdated);
|
|
265
|
+
eventBus.on('interaction:recorded', handleInteractionRecorded);
|
|
266
|
+
eventBus.on('session:beforeTerminate', handleBeforeTerminate);
|
|
267
|
+
|
|
268
|
+
// Emergency flush on page hide (catches tab close, navigation away)
|
|
269
|
+
if (typeof window !== 'undefined') {
|
|
270
|
+
window.addEventListener('pagehide', emergencyFlush);
|
|
271
|
+
window.addEventListener('beforeunload', emergencyFlush);
|
|
272
|
+
}
|
|
273
|
+
}
|