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,1094 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stub-player/lms-api.js - SCORM/cmi5/LTI API implementation with strict mode
|
|
3
|
+
*
|
|
4
|
+
* Handles the LMS State (cmiData), persistence to localStorage,
|
|
5
|
+
* and implements window.API (SCORM 1.2), window.API_1484_11 (SCORM 2004),
|
|
6
|
+
* window.cmi5, and window.lti.
|
|
7
|
+
*
|
|
8
|
+
* STRICT MODE (enabled via CI=true, ?strict=true, or /__lms/configure):
|
|
9
|
+
* Enforces real LMS behavior — lifecycle violations return 'false' with proper
|
|
10
|
+
* error codes, read-only elements reject SetValue, and format-specific rules
|
|
11
|
+
* are enforced. This catches bugs that silently pass in dev but explode in
|
|
12
|
+
* production LMSs like SCORM Cloud, Moodle, Cornerstone, etc.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const isBrowser = typeof window !== 'undefined';
|
|
16
|
+
const CONFIG = (isBrowser && window.STUB_CONFIG) ? window.STUB_CONFIG : {};
|
|
17
|
+
const STORAGE_KEY = CONFIG.storageKey || 'scorm_stub_default';
|
|
18
|
+
|
|
19
|
+
const SUSPEND_DATA_LIMITS = {
|
|
20
|
+
scorm12: 4096, // SCORM 1.2: 4KB
|
|
21
|
+
scorm2004: 64000, // SCORM 2004: 64KB
|
|
22
|
+
cmi5: Infinity, // cmi5: No limit (LRS dependent)
|
|
23
|
+
lti: Infinity // LTI: No limit (host dependent)
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ========================================
|
|
27
|
+
// SCORM Error Codes (2004 4th Edition)
|
|
28
|
+
// ========================================
|
|
29
|
+
const SCORM_ERRORS = {
|
|
30
|
+
0: 'No Error',
|
|
31
|
+
101: 'General Exception',
|
|
32
|
+
102: 'General Initialization Failure',
|
|
33
|
+
103: 'Already Initialized',
|
|
34
|
+
104: 'Content Instance Terminated',
|
|
35
|
+
111: 'General Termination Failure',
|
|
36
|
+
112: 'Termination Before Initialization',
|
|
37
|
+
113: 'Termination After Termination',
|
|
38
|
+
122: 'Retrieve Data Before Initialization',
|
|
39
|
+
123: 'Retrieve Data After Termination',
|
|
40
|
+
132: 'Store Data Before Initialization',
|
|
41
|
+
133: 'Store Data After Termination',
|
|
42
|
+
142: 'Commit Before Initialization',
|
|
43
|
+
143: 'Commit After Termination',
|
|
44
|
+
201: 'General Argument Error',
|
|
45
|
+
301: 'General Get Failure',
|
|
46
|
+
351: 'General Set Failure',
|
|
47
|
+
391: 'General Commit Failure',
|
|
48
|
+
401: 'Undefined Data Model Element',
|
|
49
|
+
402: 'Unimplemented Data Model Element',
|
|
50
|
+
403: 'Data Model Element Value Not Initialized',
|
|
51
|
+
404: 'Data Model Element Is Read Only',
|
|
52
|
+
405: 'Data Model Element Is Write Only',
|
|
53
|
+
406: 'Data Model Element Type Mismatch',
|
|
54
|
+
407: 'Data Model Element Value Out Of Range',
|
|
55
|
+
408: 'Data Model Dependency Not Established'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// SCORM 1.2 error codes (different numbering)
|
|
59
|
+
const SCORM12_ERRORS = {
|
|
60
|
+
0: 'No Error',
|
|
61
|
+
101: 'General Exception',
|
|
62
|
+
201: 'Invalid argument error',
|
|
63
|
+
202: 'Element cannot have children',
|
|
64
|
+
203: 'Element not an array - cannot have count',
|
|
65
|
+
301: 'Not initialized',
|
|
66
|
+
401: 'Not implemented error',
|
|
67
|
+
402: 'Invalid set value, element is a keyword',
|
|
68
|
+
403: 'Element is read only',
|
|
69
|
+
404: 'Element is write only',
|
|
70
|
+
405: 'Incorrect Data Type'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Read-only CMI elements (SCORM 2004)
|
|
74
|
+
const READ_ONLY_2004 = new Set([
|
|
75
|
+
'cmi.learner_id', 'cmi.learner_name', 'cmi.mode', 'cmi.credit',
|
|
76
|
+
'cmi.entry', 'cmi.total_time', 'cmi.launch_data',
|
|
77
|
+
'cmi.objectives._count', 'cmi.interactions._count',
|
|
78
|
+
'cmi.comments_from_lms._count'
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
// Read-only CMI elements (SCORM 1.2)
|
|
82
|
+
const READ_ONLY_12 = new Set([
|
|
83
|
+
'cmi.core.student_id', 'cmi.core.student_name', 'cmi.core.credit',
|
|
84
|
+
'cmi.core.entry', 'cmi.core.total_time', 'cmi.core.lesson_mode',
|
|
85
|
+
'cmi.launch_data', 'cmi.core.score._children',
|
|
86
|
+
'cmi.comments_from_lms._count'
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Valid SCORM 1.2 lesson_status values
|
|
90
|
+
const SCORM12_LESSON_STATUS = new Set([
|
|
91
|
+
'passed', 'completed', 'failed', 'incomplete', 'browsed', 'not attempted'
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// Valid cmi5 verbs (AU-allowed)
|
|
95
|
+
const CMI5_ALLOWED_VERBS = new Set([
|
|
96
|
+
'http://adlnet.gov/expapi/verbs/initialized',
|
|
97
|
+
'http://adlnet.gov/expapi/verbs/terminated',
|
|
98
|
+
'http://adlnet.gov/expapi/verbs/completed',
|
|
99
|
+
'http://adlnet.gov/expapi/verbs/passed',
|
|
100
|
+
'http://adlnet.gov/expapi/verbs/failed',
|
|
101
|
+
'https://w3id.org/xapi/adl/verbs/waived',
|
|
102
|
+
'http://adlnet.gov/expapi/verbs/experienced'
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
// ========================================
|
|
106
|
+
// UI Callbacks
|
|
107
|
+
// ========================================
|
|
108
|
+
let uiCallbacks = {
|
|
109
|
+
onStateChange: null,
|
|
110
|
+
onApiLog: null,
|
|
111
|
+
onErrorLog: null,
|
|
112
|
+
onXapiLog: null,
|
|
113
|
+
onSlideNavigation: null
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function setUiCallbacks(callbacks) {
|
|
117
|
+
uiCallbacks = { ...uiCallbacks, ...callbacks };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ========================================
|
|
121
|
+
// State
|
|
122
|
+
// ========================================
|
|
123
|
+
export let cmiData = loadState() || getDefaultCMI();
|
|
124
|
+
export let apiLog = [];
|
|
125
|
+
export let errorLog = [];
|
|
126
|
+
export let xapiLog = [];
|
|
127
|
+
export let activeFormat = 'scorm2004';
|
|
128
|
+
export let isInitialized = false;
|
|
129
|
+
export let isTerminated = false;
|
|
130
|
+
export let strictMode = false;
|
|
131
|
+
|
|
132
|
+
let lastErrorCode = 0;
|
|
133
|
+
let sessionStartTime = null;
|
|
134
|
+
let resumeSnapshot = null;
|
|
135
|
+
let cmi5VerbSequence = []; // Track verb ordering for cmi5 strict mode
|
|
136
|
+
let syncDebounceTimer = null;
|
|
137
|
+
|
|
138
|
+
// ========================================
|
|
139
|
+
// Default CMI Data Model
|
|
140
|
+
// ========================================
|
|
141
|
+
export function getDefaultCMI() {
|
|
142
|
+
return {
|
|
143
|
+
'cmi.completion_status': 'unknown',
|
|
144
|
+
'cmi.success_status': 'unknown',
|
|
145
|
+
'cmi.entry': 'ab-initio',
|
|
146
|
+
'cmi.exit': '',
|
|
147
|
+
'cmi.suspend_data': '',
|
|
148
|
+
'cmi.location': '',
|
|
149
|
+
'cmi.score.raw': '',
|
|
150
|
+
'cmi.score.scaled': '',
|
|
151
|
+
'cmi.score.min': '',
|
|
152
|
+
'cmi.score.max': '',
|
|
153
|
+
'cmi.session_time': 'PT0H0M0S',
|
|
154
|
+
'cmi.total_time': 'PT0H0M0S',
|
|
155
|
+
'cmi.learner_id': 'preview_user',
|
|
156
|
+
'cmi.learner_name': 'Preview User',
|
|
157
|
+
'cmi.mode': 'normal',
|
|
158
|
+
'cmi.credit': 'credit',
|
|
159
|
+
'_objectives': {},
|
|
160
|
+
'_interactions': []
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ========================================
|
|
165
|
+
// Strict Mode Control
|
|
166
|
+
// ========================================
|
|
167
|
+
export function setStrictMode(enabled) {
|
|
168
|
+
strictMode = enabled;
|
|
169
|
+
if (enabled) {
|
|
170
|
+
logApiCall('StrictMode', 'enabled', 'LMS will enforce real error codes and lifecycle rules');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function detectStrictMode() {
|
|
175
|
+
if (!isBrowser) return false;
|
|
176
|
+
// CI environment (E2E tests)
|
|
177
|
+
if (CONFIG.isCI) return true;
|
|
178
|
+
// URL parameter
|
|
179
|
+
const params = new URLSearchParams(window.location.search);
|
|
180
|
+
if (params.get('strict') === 'true') return true;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ========================================
|
|
185
|
+
// Error Code Management
|
|
186
|
+
// ========================================
|
|
187
|
+
function setError(code) {
|
|
188
|
+
lastErrorCode = code;
|
|
189
|
+
return code;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function clearError() {
|
|
193
|
+
lastErrorCode = 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ========================================
|
|
197
|
+
// Format Detection
|
|
198
|
+
// ========================================
|
|
199
|
+
function setActiveFormat(format) {
|
|
200
|
+
if (activeFormat === format) return;
|
|
201
|
+
activeFormat = format;
|
|
202
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('format', format);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ========================================
|
|
206
|
+
// State Persistence
|
|
207
|
+
// ========================================
|
|
208
|
+
export function loadState() {
|
|
209
|
+
if (!isBrowser) return null;
|
|
210
|
+
try {
|
|
211
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
212
|
+
if (stored) {
|
|
213
|
+
const data = JSON.parse(stored);
|
|
214
|
+
data['cmi.entry'] = 'resume';
|
|
215
|
+
return data;
|
|
216
|
+
}
|
|
217
|
+
} catch (_e) { }
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function saveState() {
|
|
222
|
+
if (!isBrowser) return;
|
|
223
|
+
try {
|
|
224
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(cmiData));
|
|
225
|
+
} catch (_e) {
|
|
226
|
+
logError('Storage Error', 'Failed to save state to localStorage', 'Check browser storage limits');
|
|
227
|
+
}
|
|
228
|
+
// Sync to server for HTTP API access
|
|
229
|
+
syncToServer();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ========================================
|
|
233
|
+
// Server Sync (for HTTP API access)
|
|
234
|
+
// ========================================
|
|
235
|
+
function syncToServer() {
|
|
236
|
+
if (!isBrowser || !CONFIG.isLive) return;
|
|
237
|
+
// Debounce to avoid flooding
|
|
238
|
+
clearTimeout(syncDebounceTimer);
|
|
239
|
+
syncDebounceTimer = setTimeout(() => {
|
|
240
|
+
try {
|
|
241
|
+
const payload = {
|
|
242
|
+
cmiData,
|
|
243
|
+
activeFormat,
|
|
244
|
+
isInitialized,
|
|
245
|
+
isTerminated,
|
|
246
|
+
strictMode,
|
|
247
|
+
apiLog: apiLog.slice(0, 50), // Last 50 entries
|
|
248
|
+
errorLog: errorLog.slice(0, 50),
|
|
249
|
+
xapiLog: xapiLog.slice(0, 50),
|
|
250
|
+
sessionStartTime
|
|
251
|
+
};
|
|
252
|
+
fetch('/__lms/sync', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify(payload)
|
|
256
|
+
}).catch(() => { /* server may not be running (static export) */ });
|
|
257
|
+
} catch (_e) { /* graceful no-op */ }
|
|
258
|
+
}, 200);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ========================================
|
|
262
|
+
// Logging
|
|
263
|
+
// ========================================
|
|
264
|
+
export function logApiCall(method, args, result, isError = false) {
|
|
265
|
+
const entry = {
|
|
266
|
+
timestamp: new Date().toLocaleTimeString(),
|
|
267
|
+
method,
|
|
268
|
+
args: args ? String(args).substring(0, 100) : '',
|
|
269
|
+
result: typeof result === 'string' ? result.substring(0, 50) : JSON.stringify(result).substring(0, 50),
|
|
270
|
+
isError
|
|
271
|
+
};
|
|
272
|
+
apiLog.unshift(entry);
|
|
273
|
+
if (apiLog.length > 100) apiLog.pop();
|
|
274
|
+
if (uiCallbacks.onApiLog) uiCallbacks.onApiLog();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function logError(type, message, hint = '', isWarning = false) {
|
|
278
|
+
errorLog.unshift({ type, message, hint, isWarning, timestamp: new Date().toLocaleTimeString() });
|
|
279
|
+
if (uiCallbacks.onErrorLog) uiCallbacks.onErrorLog();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function clearErrorLog() {
|
|
283
|
+
errorLog.length = 0;
|
|
284
|
+
if (uiCallbacks.onErrorLog) uiCallbacks.onErrorLog();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ========================================
|
|
288
|
+
// Session Time Helpers
|
|
289
|
+
// ========================================
|
|
290
|
+
function calculateSessionDuration() {
|
|
291
|
+
if (!sessionStartTime) return null;
|
|
292
|
+
const elapsed = Date.now() - sessionStartTime;
|
|
293
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
294
|
+
const hours = Math.floor(seconds / 3600);
|
|
295
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
296
|
+
const secs = seconds % 60;
|
|
297
|
+
return `PT${hours}H${minutes}M${secs}S`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function addDurations(d1, d2) {
|
|
301
|
+
// Parse ISO 8601 durations and add them
|
|
302
|
+
const parse = (d) => {
|
|
303
|
+
const match = (d || '').match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?/);
|
|
304
|
+
if (!match) return 0;
|
|
305
|
+
return (parseInt(match[1] || 0) * 3600) + (parseInt(match[2] || 0) * 60) + parseFloat(match[3] || 0);
|
|
306
|
+
};
|
|
307
|
+
const total = parse(d1) + parse(d2);
|
|
308
|
+
const hours = Math.floor(total / 3600);
|
|
309
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
310
|
+
const secs = Math.round(total % 60);
|
|
311
|
+
return `PT${hours}H${minutes}M${secs}S`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ========================================
|
|
315
|
+
// Initialization
|
|
316
|
+
// ========================================
|
|
317
|
+
export function initializeLMS() {
|
|
318
|
+
if (!isBrowser) return;
|
|
319
|
+
|
|
320
|
+
// Detect strict mode from environment
|
|
321
|
+
strictMode = detectStrictMode();
|
|
322
|
+
|
|
323
|
+
// Intercept console
|
|
324
|
+
interceptConsole();
|
|
325
|
+
|
|
326
|
+
// Initialize cmiData from restored cmi5 state (for proper resume display)
|
|
327
|
+
const cmi5State = cmiData._cmi5State?.cmi5_state;
|
|
328
|
+
if (cmi5State) {
|
|
329
|
+
if (cmi5State.bookmark !== undefined) cmiData['cmi.location'] = cmi5State.bookmark || '';
|
|
330
|
+
if (cmi5State.completionStatus !== undefined) cmiData['cmi.completion_status'] = cmi5State.completionStatus;
|
|
331
|
+
if (cmi5State.successStatus !== undefined) cmiData['cmi.success_status'] = cmi5State.successStatus;
|
|
332
|
+
if (cmi5State.score !== undefined && cmi5State.score !== null) cmiData['cmi.score.scaled'] = String(cmi5State.score);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Expose APIs to window
|
|
336
|
+
window.API_1484_11 = API_1484_11;
|
|
337
|
+
window.API = API;
|
|
338
|
+
window.cmi5 = cmi5;
|
|
339
|
+
window.lti = lti;
|
|
340
|
+
|
|
341
|
+
// Expose stub player utilities for HMR
|
|
342
|
+
window.stubPlayer = { clearErrors: clearErrorLog };
|
|
343
|
+
|
|
344
|
+
// Expose live state for MCP headless browser access
|
|
345
|
+
window._stubPlayerState = { cmiData, apiLog, errorLog, xapiLog };
|
|
346
|
+
|
|
347
|
+
// Initial sync to server
|
|
348
|
+
syncToServer();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function interceptConsole() {
|
|
352
|
+
if (!isBrowser) return;
|
|
353
|
+
|
|
354
|
+
const originalError = console.error;
|
|
355
|
+
const originalWarn = console.warn;
|
|
356
|
+
|
|
357
|
+
console.error = function (...args) {
|
|
358
|
+
const message = args.map(a => {
|
|
359
|
+
if (a instanceof Error) return a.message + (a.stack ? '\n' + a.stack.split('\n').slice(0, 3).join('\n') : '');
|
|
360
|
+
if (typeof a === 'object') try { return JSON.stringify(a); } catch { return String(a); }
|
|
361
|
+
return String(a);
|
|
362
|
+
}).join(' ');
|
|
363
|
+
logError('Console Error', message.substring(0, 500), '', false);
|
|
364
|
+
originalError.apply(console, args);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
console.warn = function (...args) {
|
|
368
|
+
const message = args.map(a => {
|
|
369
|
+
if (typeof a === 'object') try { return JSON.stringify(a); } catch { return String(a); }
|
|
370
|
+
return String(a);
|
|
371
|
+
}).join(' ');
|
|
372
|
+
logError('Console Warning', message.substring(0, 500), '', true);
|
|
373
|
+
originalWarn.apply(console, args);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
window.addEventListener('error', function (event) {
|
|
377
|
+
const hint = event.filename ? event.filename.split('/').pop() + ':' + event.lineno : '';
|
|
378
|
+
logError('Uncaught Error', event.message || 'Unknown error', hint, false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
window.addEventListener('unhandledrejection', function (event) {
|
|
382
|
+
const message = event.reason?.message || String(event.reason);
|
|
383
|
+
logError('Unhandled Promise', message.substring(0, 500), '', false);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ========================================
|
|
388
|
+
// CMI Validation & Checks
|
|
389
|
+
// ========================================
|
|
390
|
+
const CMI_VALIDATION = {
|
|
391
|
+
'cmi.completion_status': { values: ['completed', 'incomplete', 'not attempted', 'unknown'] },
|
|
392
|
+
'cmi.success_status': { values: ['passed', 'failed', 'unknown'] },
|
|
393
|
+
'cmi.exit': { values: ['', 'time-out', 'suspend', 'logout', 'normal'] },
|
|
394
|
+
'cmi.score.scaled': { type: 'decimal', min: -1, max: 1 },
|
|
395
|
+
'cmi.score.raw': { type: 'number' },
|
|
396
|
+
'cmi.score.min': { type: 'number' },
|
|
397
|
+
'cmi.score.max': { type: 'number' }
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
function validateSetValue(element, value) {
|
|
401
|
+
const validation = CMI_VALIDATION[element];
|
|
402
|
+
if (!validation) return true;
|
|
403
|
+
|
|
404
|
+
if (validation.values && !validation.values.includes(value)) {
|
|
405
|
+
logError('Invalid Value', element + ' = "' + value + '"', 'Valid values: ' + validation.values.join(', '), !strictMode);
|
|
406
|
+
if (strictMode) {
|
|
407
|
+
setError(407);
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (validation.type === 'number' && value !== '' && isNaN(Number(value))) {
|
|
413
|
+
logError('Type Error', element + ' expects a number, got "' + value + '"', 'Convert to number before setting', !strictMode);
|
|
414
|
+
if (strictMode) {
|
|
415
|
+
setError(406);
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (validation.type === 'decimal') {
|
|
421
|
+
const num = Number(value);
|
|
422
|
+
if (isNaN(num) || num < validation.min || num > validation.max) {
|
|
423
|
+
logError('Range Error', element + ' = ' + value + ' (must be ' + validation.min + ' to ' + validation.max + ')', '', !strictMode);
|
|
424
|
+
if (strictMode) {
|
|
425
|
+
setError(407);
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function checkSuspendDataSize(value) {
|
|
434
|
+
const size = value ? value.length : 0;
|
|
435
|
+
const limit = SUSPEND_DATA_LIMITS[activeFormat] || 64000;
|
|
436
|
+
const percentUsed = (size / limit) * 100;
|
|
437
|
+
|
|
438
|
+
if (limit !== Infinity) {
|
|
439
|
+
if (size > limit) {
|
|
440
|
+
logError('Suspend Data Exceeded', 'Size: ' + (size / 1024).toFixed(1) + 'KB exceeds ' + (limit / 1024) + 'KB limit', 'Real LMS will fail. Reduce data or switch to SCORM 2004/cmi5.', false);
|
|
441
|
+
if (strictMode) {
|
|
442
|
+
setError(405);
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
} else if (percentUsed > 75) {
|
|
446
|
+
logError('Suspend Data Warning', 'Size: ' + (size / 1024).toFixed(1) + 'KB (' + percentUsed.toFixed(0) + '% of ' + (limit / 1024) + 'KB limit)', 'Getting close to ' + activeFormat.toUpperCase() + ' limit.', true);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ========================================
|
|
453
|
+
// Lifecycle Enforcement
|
|
454
|
+
// ========================================
|
|
455
|
+
function checkLifecycleGet() {
|
|
456
|
+
if (!isInitialized && !isTerminated) {
|
|
457
|
+
logError('Before Initialize', 'GetValue called before Initialize', 'Real LMS will fail. Call Initialize first.', !strictMode);
|
|
458
|
+
if (strictMode) { setError(122); return false; }
|
|
459
|
+
}
|
|
460
|
+
if (isTerminated) {
|
|
461
|
+
logError('After Terminate', 'GetValue called after Terminate', 'Real LMS will fail.', !strictMode);
|
|
462
|
+
if (strictMode) { setError(123); return false; }
|
|
463
|
+
}
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function checkLifecycleSet() {
|
|
468
|
+
if (!isInitialized && !isTerminated) {
|
|
469
|
+
logError('Before Initialize', 'SetValue called before Initialize', 'Real LMS will fail. Call Initialize first.', !strictMode);
|
|
470
|
+
if (strictMode) { setError(132); return false; }
|
|
471
|
+
}
|
|
472
|
+
if (isTerminated) {
|
|
473
|
+
logError('After Terminate', 'SetValue called after Terminate', 'Real LMS will fail.', !strictMode);
|
|
474
|
+
if (strictMode) { setError(133); return false; }
|
|
475
|
+
}
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function checkLifecycleCommit() {
|
|
480
|
+
if (!isInitialized && !isTerminated) {
|
|
481
|
+
logError('Before Initialize', 'Commit called before Initialize', 'Real LMS will fail.', !strictMode);
|
|
482
|
+
if (strictMode) { setError(142); return false; }
|
|
483
|
+
}
|
|
484
|
+
if (isTerminated) {
|
|
485
|
+
logError('After Terminate', 'Commit called after Terminate', 'Real LMS will fail.', !strictMode);
|
|
486
|
+
if (strictMode) { setError(143); return false; }
|
|
487
|
+
}
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function checkReadOnly2004(element) {
|
|
492
|
+
if (READ_ONLY_2004.has(element)) {
|
|
493
|
+
logError('Read Only', 'SetValue on read-only element: ' + element, 'Real LMS will reject this.', !strictMode);
|
|
494
|
+
if (strictMode) { setError(404); return false; }
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function checkReadOnly12(element) {
|
|
500
|
+
if (READ_ONLY_12.has(element)) {
|
|
501
|
+
logError('Read Only', 'LMSSetValue on read-only element: ' + element, 'Real LMS will reject this.', !strictMode);
|
|
502
|
+
if (strictMode) { setError(403); return false; }
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function checkTerminateCompatibility() {
|
|
508
|
+
if (!cmiData['cmi.exit'] || cmiData['cmi.exit'] === '') {
|
|
509
|
+
logError('Missing cmi.exit', 'cmi.exit not set before Terminate', 'Set cmi.exit to "suspend" for resume, "" to discard, or "logout"/"normal" to finish.', true);
|
|
510
|
+
}
|
|
511
|
+
if (cmiData['cmi.exit'] === 'suspend' && cmiData['cmi.completion_status'] === 'unknown') {
|
|
512
|
+
logError('Suspend Without Progress', 'Suspending with completion_status = "unknown"', 'Consider setting to "incomplete".', true);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Auto-calculate session time if not set
|
|
516
|
+
if ((!cmiData['cmi.session_time'] || cmiData['cmi.session_time'] === 'PT0H0M0S') && sessionStartTime) {
|
|
517
|
+
const duration = calculateSessionDuration();
|
|
518
|
+
if (duration) {
|
|
519
|
+
cmiData['cmi.session_time'] = duration;
|
|
520
|
+
logApiCall('Auto', 'cmi.session_time = ' + duration, 'calculated');
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Accumulate total_time
|
|
525
|
+
if (cmiData['cmi.session_time'] && cmiData['cmi.session_time'] !== 'PT0H0M0S') {
|
|
526
|
+
cmiData['cmi.total_time'] = addDurations(cmiData['cmi.total_time'], cmiData['cmi.session_time']);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function captureResumeSnapshot() {
|
|
531
|
+
if (cmiData['cmi.entry'] === 'resume') {
|
|
532
|
+
resumeSnapshot = {
|
|
533
|
+
location: cmiData['cmi.location'] || null,
|
|
534
|
+
completion: cmiData['cmi.completion_status'],
|
|
535
|
+
success: cmiData['cmi.success_status'],
|
|
536
|
+
suspendDataLength: (cmiData['cmi.suspend_data'] || '').length,
|
|
537
|
+
exit: cmiData['cmi.exit']
|
|
538
|
+
};
|
|
539
|
+
if (resumeSnapshot.location) logApiCall('Resume', 'location=' + resumeSnapshot.location, 'restored', false);
|
|
540
|
+
if (resumeSnapshot.suspendDataLength > 0) logApiCall('Resume', 'suspend_data=' + (resumeSnapshot.suspendDataLength / 1024).toFixed(1) + 'KB', 'restored', false);
|
|
541
|
+
if (resumeSnapshot.completion !== 'unknown') logApiCall('Resume', 'completion=' + resumeSnapshot.completion, 'restored', false);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function notifySlideChange(slideId) {
|
|
546
|
+
if (uiCallbacks.onSlideNavigation) uiCallbacks.onSlideNavigation(slideId);
|
|
547
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('slide', slideId);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ========================================
|
|
551
|
+
// SCORM 2004 API (API_1484_11)
|
|
552
|
+
// ========================================
|
|
553
|
+
const API_1484_11 = {
|
|
554
|
+
Initialize: function () {
|
|
555
|
+
clearError();
|
|
556
|
+
setActiveFormat('scorm2004');
|
|
557
|
+
|
|
558
|
+
if (isInitialized && !isTerminated) {
|
|
559
|
+
logError('Already Initialized', 'Initialize called when already initialized', 'Real LMS returns false.', !strictMode);
|
|
560
|
+
if (strictMode) { setError(103); logApiCall('Initialize', null, 'false', true); return 'false'; }
|
|
561
|
+
}
|
|
562
|
+
if (isTerminated) {
|
|
563
|
+
logError('After Termination', 'Initialize called after Terminate', 'Content instance terminated.', !strictMode);
|
|
564
|
+
if (strictMode) { setError(104); logApiCall('Initialize', null, 'false', true); return 'false'; }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
isInitialized = true;
|
|
568
|
+
isTerminated = false;
|
|
569
|
+
sessionStartTime = Date.now();
|
|
570
|
+
captureResumeSnapshot();
|
|
571
|
+
logApiCall('Initialize', null, 'true');
|
|
572
|
+
syncToServer();
|
|
573
|
+
return 'true';
|
|
574
|
+
},
|
|
575
|
+
Terminate: function () {
|
|
576
|
+
clearError();
|
|
577
|
+
|
|
578
|
+
if (!isInitialized) {
|
|
579
|
+
logError('Before Initialize', 'Terminate called before Initialize', 'Real LMS returns false.', !strictMode);
|
|
580
|
+
if (strictMode) { setError(112); logApiCall('Terminate', null, 'false', true); return 'false'; }
|
|
581
|
+
}
|
|
582
|
+
if (isTerminated) {
|
|
583
|
+
logError('After Termination', 'Terminate called after already terminated', 'Real LMS returns false.', !strictMode);
|
|
584
|
+
if (strictMode) { setError(113); logApiCall('Terminate', null, 'false', true); return 'false'; }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
checkTerminateCompatibility();
|
|
588
|
+
saveState();
|
|
589
|
+
isTerminated = true;
|
|
590
|
+
logApiCall('Terminate', null, 'true');
|
|
591
|
+
syncToServer();
|
|
592
|
+
return 'true';
|
|
593
|
+
},
|
|
594
|
+
GetValue: function (element) {
|
|
595
|
+
clearError();
|
|
596
|
+
|
|
597
|
+
if (!checkLifecycleGet()) {
|
|
598
|
+
logApiCall('GetValue', element, '', true);
|
|
599
|
+
return '';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let value = '';
|
|
603
|
+
if (element.startsWith('cmi.objectives.')) value = handleObjectiveGet(element);
|
|
604
|
+
else if (element.startsWith('cmi.interactions.')) value = handleInteractionGet(element);
|
|
605
|
+
else if (element === 'cmi.objectives._count') value = String(Object.keys(cmiData._objectives || {}).length);
|
|
606
|
+
else if (element === 'cmi.interactions._count') value = String((cmiData._interactions || []).length);
|
|
607
|
+
else if (cmiData[element] !== undefined) value = cmiData[element];
|
|
608
|
+
else {
|
|
609
|
+
// Unknown element
|
|
610
|
+
if (strictMode) {
|
|
611
|
+
setError(401);
|
|
612
|
+
logApiCall('GetValue', element, '', true);
|
|
613
|
+
return '';
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
logApiCall('GetValue', element, value);
|
|
618
|
+
return value;
|
|
619
|
+
},
|
|
620
|
+
SetValue: function (element, value) {
|
|
621
|
+
clearError();
|
|
622
|
+
|
|
623
|
+
if (!checkLifecycleSet()) {
|
|
624
|
+
logApiCall('SetValue', element + ' = ' + value, 'false', true);
|
|
625
|
+
return 'false';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!checkReadOnly2004(element)) {
|
|
629
|
+
logApiCall('SetValue', element + ' = ' + value, 'false (read-only)', true);
|
|
630
|
+
return 'false';
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!validateSetValue(element, value)) {
|
|
634
|
+
logApiCall('SetValue', element + ' = ' + value, 'false (invalid)', true);
|
|
635
|
+
return 'false';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (element === 'cmi.suspend_data') {
|
|
639
|
+
if (!checkSuspendDataSize(value)) {
|
|
640
|
+
logApiCall('SetValue', element + ' = [' + value.length + ' chars]', 'false (too large)', true);
|
|
641
|
+
return 'false';
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Format-specific: reject cmi.score.scaled in strict mode for SCORM 1.2
|
|
646
|
+
// (shouldn't happen via API_1484_11, but guard anyway)
|
|
647
|
+
|
|
648
|
+
if (element.startsWith('cmi.objectives.')) handleObjectiveSet(element, value);
|
|
649
|
+
else if (element.startsWith('cmi.interactions.')) handleInteractionSet(element, value);
|
|
650
|
+
else {
|
|
651
|
+
cmiData[element] = value;
|
|
652
|
+
if (element === 'cmi.location') notifySlideChange(value);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
logApiCall('SetValue', element + ' = ' + value, 'true');
|
|
656
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('data');
|
|
657
|
+
return 'true';
|
|
658
|
+
},
|
|
659
|
+
Commit: function () {
|
|
660
|
+
clearError();
|
|
661
|
+
if (!checkLifecycleCommit()) {
|
|
662
|
+
logApiCall('Commit', null, 'false', true);
|
|
663
|
+
return 'false';
|
|
664
|
+
}
|
|
665
|
+
saveState();
|
|
666
|
+
logApiCall('Commit', null, 'true');
|
|
667
|
+
return 'true';
|
|
668
|
+
},
|
|
669
|
+
GetLastError: function () {
|
|
670
|
+
return String(lastErrorCode);
|
|
671
|
+
},
|
|
672
|
+
GetErrorString: function (code) {
|
|
673
|
+
return SCORM_ERRORS[Number(code)] || 'Unknown Error';
|
|
674
|
+
},
|
|
675
|
+
GetDiagnostic: function (code) {
|
|
676
|
+
const numCode = Number(code);
|
|
677
|
+
if (numCode === 0) return 'No error condition exists.';
|
|
678
|
+
const base = SCORM_ERRORS[numCode] || 'Unknown error';
|
|
679
|
+
return `Error ${numCode}: ${base}. Check API call sequence and element validity.`;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// ========================================
|
|
684
|
+
// SCORM 1.2 API
|
|
685
|
+
// ========================================
|
|
686
|
+
const API = {
|
|
687
|
+
LMSInitialize: function () {
|
|
688
|
+
clearError();
|
|
689
|
+
setActiveFormat('scorm12');
|
|
690
|
+
|
|
691
|
+
if (isInitialized && !isTerminated) {
|
|
692
|
+
logError('Already Initialized', 'LMSInitialize called when already initialized', 'Real LMS returns false.', !strictMode);
|
|
693
|
+
if (strictMode) { setError(101); logApiCall('LMSInitialize', null, 'false', true); return 'false'; }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
isInitialized = true;
|
|
697
|
+
isTerminated = false;
|
|
698
|
+
sessionStartTime = Date.now();
|
|
699
|
+
captureResumeSnapshot();
|
|
700
|
+
logApiCall('LMSInitialize', null, 'true');
|
|
701
|
+
syncToServer();
|
|
702
|
+
return 'true';
|
|
703
|
+
},
|
|
704
|
+
LMSFinish: function () {
|
|
705
|
+
clearError();
|
|
706
|
+
|
|
707
|
+
if (!isInitialized) {
|
|
708
|
+
logError('Before Initialize', 'LMSFinish called before LMSInitialize', 'Real LMS returns false.', !strictMode);
|
|
709
|
+
if (strictMode) { setError(301); logApiCall('LMSFinish', null, 'false', true); return 'false'; }
|
|
710
|
+
}
|
|
711
|
+
if (isTerminated) {
|
|
712
|
+
logError('After Termination', 'LMSFinish called after already terminated', 'Real LMS returns false.', !strictMode);
|
|
713
|
+
if (strictMode) { setError(101); logApiCall('LMSFinish', null, 'false', true); return 'false'; }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
checkTerminateCompatibility();
|
|
717
|
+
saveState();
|
|
718
|
+
isTerminated = true;
|
|
719
|
+
logApiCall('LMSFinish', null, 'true');
|
|
720
|
+
syncToServer();
|
|
721
|
+
return 'true';
|
|
722
|
+
},
|
|
723
|
+
LMSGetValue: function (element) {
|
|
724
|
+
clearError();
|
|
725
|
+
|
|
726
|
+
if (!isInitialized) {
|
|
727
|
+
logError('Before Initialize', 'LMSGetValue called before LMSInitialize', 'Real LMS returns empty.', !strictMode);
|
|
728
|
+
if (strictMode) { setError(301); logApiCall('LMSGetValue', element, '', true); return ''; }
|
|
729
|
+
}
|
|
730
|
+
if (isTerminated) {
|
|
731
|
+
logError('After Terminate', 'LMSGetValue called after LMSFinish', 'Real LMS returns empty.', !strictMode);
|
|
732
|
+
if (strictMode) { setError(301); logApiCall('LMSGetValue', element, '', true); return ''; }
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (element === 'cmi.core.lesson_status') {
|
|
736
|
+
const completion = cmiData['cmi.completion_status'] || '';
|
|
737
|
+
const success = cmiData['cmi.success_status'] || '';
|
|
738
|
+
let status = '';
|
|
739
|
+
if (success === 'passed') status = 'passed';
|
|
740
|
+
else if (success === 'failed') status = 'failed';
|
|
741
|
+
else if (completion === 'completed') status = 'completed';
|
|
742
|
+
else if (completion === 'incomplete') status = 'incomplete';
|
|
743
|
+
else status = 'not attempted';
|
|
744
|
+
logApiCall('LMSGetValue', element, status);
|
|
745
|
+
return status;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Strict: reject SCORM 2004 elements in SCORM 1.2 context
|
|
749
|
+
if (strictMode && !element.startsWith('cmi.core.') && !element.startsWith('cmi.suspend_data') && !element.startsWith('cmi.launch_data') && !element.startsWith('cmi.objectives.') && !element.startsWith('cmi.interactions.') && !element.startsWith('cmi.student_data.') && !element.startsWith('cmi.comments')) {
|
|
750
|
+
logError('Wrong Format', 'SCORM 1.2 does not support element: ' + element, 'Use SCORM 1.2 element names (cmi.core.*).');
|
|
751
|
+
setError(201);
|
|
752
|
+
logApiCall('LMSGetValue', element, '', true);
|
|
753
|
+
return '';
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const mapped = mapScorm12Element(element);
|
|
757
|
+
let value = cmiData[mapped] !== undefined ? cmiData[mapped] : '';
|
|
758
|
+
logApiCall('LMSGetValue', element, value);
|
|
759
|
+
return value;
|
|
760
|
+
},
|
|
761
|
+
LMSSetValue: function (element, value) {
|
|
762
|
+
clearError();
|
|
763
|
+
|
|
764
|
+
if (!isInitialized) {
|
|
765
|
+
logError('Before Initialize', 'LMSSetValue called before LMSInitialize', 'Real LMS returns false.', !strictMode);
|
|
766
|
+
if (strictMode) { setError(301); logApiCall('LMSSetValue', element + ' = ' + value, 'false', true); return 'false'; }
|
|
767
|
+
}
|
|
768
|
+
if (isTerminated) {
|
|
769
|
+
logError('After Terminate', 'LMSSetValue called after LMSFinish', 'Real LMS returns false.', !strictMode);
|
|
770
|
+
if (strictMode) { setError(301); logApiCall('LMSSetValue', element + ' = ' + value, 'false', true); return 'false'; }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!checkReadOnly12(element)) {
|
|
774
|
+
logApiCall('LMSSetValue', element + ' = ' + value, 'false (read-only)', true);
|
|
775
|
+
return 'false';
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (element === 'cmi.suspend_data') {
|
|
779
|
+
if (!checkSuspendDataSize(value)) {
|
|
780
|
+
logApiCall('LMSSetValue', element + ' = [' + value.length + ' chars]', 'false (too large)', true);
|
|
781
|
+
return 'false';
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (element === 'cmi.core.lesson_status') {
|
|
786
|
+
// Strict: validate lesson_status values
|
|
787
|
+
if (strictMode && !SCORM12_LESSON_STATUS.has(value)) {
|
|
788
|
+
logError('Invalid Value', 'cmi.core.lesson_status = "' + value + '"', 'Valid: ' + [...SCORM12_LESSON_STATUS].join(', '));
|
|
789
|
+
setError(405);
|
|
790
|
+
logApiCall('LMSSetValue', element + ' = ' + value, 'false', true);
|
|
791
|
+
return 'false';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (value === 'passed') { cmiData['cmi.completion_status'] = 'completed'; cmiData['cmi.success_status'] = 'passed'; }
|
|
795
|
+
else if (value === 'failed') { cmiData['cmi.completion_status'] = 'completed'; cmiData['cmi.success_status'] = 'failed'; }
|
|
796
|
+
else if (value === 'completed') { cmiData['cmi.completion_status'] = 'completed'; }
|
|
797
|
+
else if (value === 'incomplete') { cmiData['cmi.completion_status'] = 'incomplete'; }
|
|
798
|
+
else { cmiData['cmi.completion_status'] = value; }
|
|
799
|
+
logApiCall('LMSSetValue', element + ' = ' + value, 'true');
|
|
800
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('data');
|
|
801
|
+
return 'true';
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Strict: validate session_time format (SCORM 1.2 uses HHHH:MM:SS, not ISO 8601)
|
|
805
|
+
if (strictMode && element === 'cmi.core.session_time') {
|
|
806
|
+
if (!/^\d{2,4}:\d{2}:\d{2}(\.\d+)?$/.test(value)) {
|
|
807
|
+
logError('Format Error', 'cmi.core.session_time = "' + value + '"', 'SCORM 1.2 requires HHHH:MM:SS format, not ISO 8601.');
|
|
808
|
+
setError(405);
|
|
809
|
+
logApiCall('LMSSetValue', element + ' = ' + value, 'false', true);
|
|
810
|
+
return 'false';
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Strict: reject SCORM 2004-only elements in SCORM 1.2 context
|
|
815
|
+
if (strictMode && !element.startsWith('cmi.core.') && !element.startsWith('cmi.suspend_data') && !element.startsWith('cmi.launch_data') && !element.startsWith('cmi.objectives.') && !element.startsWith('cmi.interactions.') && !element.startsWith('cmi.student_data.') && !element.startsWith('cmi.comments')) {
|
|
816
|
+
logError('Wrong Format', 'SCORM 1.2 does not support element: ' + element, 'Use SCORM 1.2 element names (cmi.core.*).');
|
|
817
|
+
setError(201);
|
|
818
|
+
logApiCall('LMSSetValue', element + ' = ' + value, 'false', true);
|
|
819
|
+
return 'false';
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const mapped = mapScorm12Element(element);
|
|
823
|
+
cmiData[mapped] = value;
|
|
824
|
+
if (element === 'cmi.core.lesson_location') notifySlideChange(value);
|
|
825
|
+
logApiCall('LMSSetValue', element + ' = ' + value, 'true');
|
|
826
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('data');
|
|
827
|
+
return 'true';
|
|
828
|
+
},
|
|
829
|
+
LMSCommit: function () {
|
|
830
|
+
clearError();
|
|
831
|
+
if (!isInitialized) {
|
|
832
|
+
if (strictMode) { setError(301); logApiCall('LMSCommit', null, 'false', true); return 'false'; }
|
|
833
|
+
}
|
|
834
|
+
if (isTerminated) {
|
|
835
|
+
logError('After Terminate', 'LMSCommit called after LMSFinish', 'Real LMS returns false.', !strictMode);
|
|
836
|
+
if (strictMode) { setError(301); logApiCall('LMSCommit', null, 'false', true); return 'false'; }
|
|
837
|
+
}
|
|
838
|
+
saveState();
|
|
839
|
+
logApiCall('LMSCommit', null, 'true');
|
|
840
|
+
return 'true';
|
|
841
|
+
},
|
|
842
|
+
LMSGetLastError: function () {
|
|
843
|
+
return String(lastErrorCode);
|
|
844
|
+
},
|
|
845
|
+
LMSGetErrorString: function (code) {
|
|
846
|
+
return SCORM12_ERRORS[Number(code)] || 'Unknown Error';
|
|
847
|
+
},
|
|
848
|
+
LMSGetDiagnostic: function (code) {
|
|
849
|
+
const numCode = Number(code);
|
|
850
|
+
if (numCode === 0) return 'No error condition exists.';
|
|
851
|
+
return SCORM12_ERRORS[numCode] || `Error ${numCode}`;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
function mapScorm12Element(element) {
|
|
856
|
+
const mappings = {
|
|
857
|
+
'cmi.core.student_id': 'cmi.learner_id',
|
|
858
|
+
'cmi.core.student_name': 'cmi.learner_name',
|
|
859
|
+
'cmi.core.lesson_location': 'cmi.location',
|
|
860
|
+
'cmi.core.lesson_status': 'cmi.completion_status',
|
|
861
|
+
'cmi.core.score.raw': 'cmi.score.raw',
|
|
862
|
+
'cmi.core.score.min': 'cmi.score.min',
|
|
863
|
+
'cmi.core.score.max': 'cmi.score.max',
|
|
864
|
+
'cmi.core.session_time': 'cmi.session_time',
|
|
865
|
+
'cmi.core.total_time': 'cmi.total_time',
|
|
866
|
+
'cmi.core.exit': 'cmi.exit',
|
|
867
|
+
'cmi.core.entry': 'cmi.entry',
|
|
868
|
+
'cmi.core.credit': 'cmi.credit',
|
|
869
|
+
'cmi.core.mode': 'cmi.mode',
|
|
870
|
+
'cmi.suspend_data': 'cmi.suspend_data',
|
|
871
|
+
'cmi.launch_data': 'cmi.launch_data'
|
|
872
|
+
};
|
|
873
|
+
return mappings[element] || element;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ========================================
|
|
877
|
+
// cmi5 API
|
|
878
|
+
// ========================================
|
|
879
|
+
const cmi5 = {
|
|
880
|
+
initialized: false,
|
|
881
|
+
state: cmiData._cmi5State || {},
|
|
882
|
+
initialize: function () {
|
|
883
|
+
setActiveFormat('cmi5');
|
|
884
|
+
this.initialized = true;
|
|
885
|
+
isInitialized = true;
|
|
886
|
+
isTerminated = false;
|
|
887
|
+
sessionStartTime = Date.now();
|
|
888
|
+
cmi5VerbSequence = [];
|
|
889
|
+
captureResumeSnapshot();
|
|
890
|
+
logApiCall('cmi5.initialize', null, 'true');
|
|
891
|
+
syncToServer();
|
|
892
|
+
return true;
|
|
893
|
+
},
|
|
894
|
+
getState: function (key) {
|
|
895
|
+
setActiveFormat('cmi5');
|
|
896
|
+
const value = this.state[key] || null;
|
|
897
|
+
logApiCall('cmi5.getState', key, value ? 'object' : 'null');
|
|
898
|
+
return value;
|
|
899
|
+
},
|
|
900
|
+
setState: function (key, data) {
|
|
901
|
+
setActiveFormat('cmi5');
|
|
902
|
+
this.state[key] = data;
|
|
903
|
+
cmiData._cmi5State = this.state;
|
|
904
|
+
|
|
905
|
+
if (key === 'cmi5_state' && data) {
|
|
906
|
+
cmiData['cmi.location'] = data.bookmark || '';
|
|
907
|
+
cmiData['cmi.completion_status'] = data.completionStatus || 'unknown';
|
|
908
|
+
cmiData['cmi.success_status'] = data.successStatus || 'unknown';
|
|
909
|
+
if (data.score !== undefined && data.score !== null) {
|
|
910
|
+
cmiData['cmi.score.scaled'] = String(data.score);
|
|
911
|
+
// Derive SCORM-compatible raw/min/max from cmi5 scaled (0-1) score
|
|
912
|
+
cmiData['cmi.score.raw'] = String(Math.round(data.score * 100 * 100) / 100);
|
|
913
|
+
cmiData['cmi.score.min'] = '0';
|
|
914
|
+
cmiData['cmi.score.max'] = '100';
|
|
915
|
+
}
|
|
916
|
+
if (data.bookmark) notifySlideChange(data.bookmark);
|
|
917
|
+
}
|
|
918
|
+
if (key === 'suspend_data') {
|
|
919
|
+
try { cmiData['cmi.suspend_data'] = JSON.stringify(data); } catch (_e) { }
|
|
920
|
+
}
|
|
921
|
+
saveState();
|
|
922
|
+
logApiCall('cmi5.setState', key, 'saved');
|
|
923
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('data');
|
|
924
|
+
},
|
|
925
|
+
sendStatement: function (statement) {
|
|
926
|
+
// cmi5 xAPI statement handling
|
|
927
|
+
if (statement?.verb?.id) {
|
|
928
|
+
// Track verb sequence for strict mode validation
|
|
929
|
+
cmi5VerbSequence.push(statement.verb.id);
|
|
930
|
+
|
|
931
|
+
if (strictMode) {
|
|
932
|
+
// Validate allowed verbs
|
|
933
|
+
if (!CMI5_ALLOWED_VERBS.has(statement.verb.id) && !statement.verb.id.includes('experienced')) {
|
|
934
|
+
logError('cmi5 Verb', 'Verb not allowed by cmi5 spec: ' + statement.verb.id, 'Only cmi5-defined verbs may be sent by the AU.', false);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Validate sequence: initialized must come first
|
|
938
|
+
if (cmi5VerbSequence.length === 1 && !statement.verb.id.endsWith('initialized')) {
|
|
939
|
+
logError('cmi5 Sequence', 'First statement must use "initialized" verb', 'Got: ' + statement.verb.id, false);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Validate: completed/passed/failed cannot come after terminated
|
|
943
|
+
const terminatedIdx = cmi5VerbSequence.findIndex(v => v.endsWith('terminated'));
|
|
944
|
+
if (terminatedIdx >= 0 && terminatedIdx < cmi5VerbSequence.length - 1) {
|
|
945
|
+
logError('cmi5 Sequence', 'Statement sent after "terminated"', 'No statements allowed after terminated.', false);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
logXapiStatement(statement);
|
|
952
|
+
logApiCall('cmi5.sendStatement', statement?.verb?.display?.['en-US'] || statement?.verb?.id || 'unknown', 'sent');
|
|
953
|
+
},
|
|
954
|
+
recordInteraction: function (data) {
|
|
955
|
+
// Direct interaction recording for mock mode — bypasses strict cmi5 verb validation
|
|
956
|
+
if (!cmiData._interactions) cmiData._interactions = [];
|
|
957
|
+
cmiData._interactions.push({
|
|
958
|
+
id: data.id || '',
|
|
959
|
+
type: data.type || 'other',
|
|
960
|
+
result: data.correct ? 'correct' : 'incorrect',
|
|
961
|
+
response: String(data.response ?? ''),
|
|
962
|
+
description: data.description || ''
|
|
963
|
+
});
|
|
964
|
+
saveState();
|
|
965
|
+
logApiCall('cmi5.recordInteraction', data.id, 'saved');
|
|
966
|
+
},
|
|
967
|
+
terminate: function () {
|
|
968
|
+
checkTerminateCompatibility();
|
|
969
|
+
saveState();
|
|
970
|
+
isTerminated = true;
|
|
971
|
+
this.initialized = false;
|
|
972
|
+
logApiCall('cmi5.terminate', null, 'sent');
|
|
973
|
+
syncToServer();
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// ========================================
|
|
978
|
+
// LTI API
|
|
979
|
+
// ========================================
|
|
980
|
+
const lti = {
|
|
981
|
+
initialized: false,
|
|
982
|
+
state: cmiData._ltiState || {},
|
|
983
|
+
launchData: {
|
|
984
|
+
userId: 'preview_user',
|
|
985
|
+
name: 'Preview User',
|
|
986
|
+
roles: ['Learner'],
|
|
987
|
+
resourceLinkId: 'preview-resource',
|
|
988
|
+
contextId: 'preview-context'
|
|
989
|
+
},
|
|
990
|
+
initialize: function () {
|
|
991
|
+
setActiveFormat('lti');
|
|
992
|
+
this.initialized = true;
|
|
993
|
+
isInitialized = true;
|
|
994
|
+
isTerminated = false;
|
|
995
|
+
sessionStartTime = Date.now();
|
|
996
|
+
captureResumeSnapshot();
|
|
997
|
+
logApiCall('lti.initialize', null, 'true');
|
|
998
|
+
syncToServer();
|
|
999
|
+
return true;
|
|
1000
|
+
},
|
|
1001
|
+
getState: function (key) {
|
|
1002
|
+
setActiveFormat('lti');
|
|
1003
|
+
const value = this.state[key] || null;
|
|
1004
|
+
logApiCall('lti.getState', key, value ? 'object' : 'null');
|
|
1005
|
+
return value;
|
|
1006
|
+
},
|
|
1007
|
+
setState: function (key, data) {
|
|
1008
|
+
setActiveFormat('lti');
|
|
1009
|
+
this.state[key] = data;
|
|
1010
|
+
cmiData._ltiState = this.state;
|
|
1011
|
+
|
|
1012
|
+
if (key === 'lti_state' && data) {
|
|
1013
|
+
cmiData['cmi.location'] = data.bookmark || '';
|
|
1014
|
+
cmiData['cmi.completion_status'] = data.completionStatus || 'unknown';
|
|
1015
|
+
cmiData['cmi.success_status'] = data.successStatus || 'unknown';
|
|
1016
|
+
if (data.score !== undefined && data.score !== null) cmiData['cmi.score.scaled'] = String(data.score);
|
|
1017
|
+
if (data.bookmark) notifySlideChange(data.bookmark);
|
|
1018
|
+
}
|
|
1019
|
+
if (key === 'suspend_data') {
|
|
1020
|
+
try { cmiData['cmi.suspend_data'] = JSON.stringify(data); } catch (_e) { }
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Strict: validate score range for AGS
|
|
1024
|
+
if (strictMode && key === 'lti_state' && data?.score !== undefined) {
|
|
1025
|
+
if (data.score < 0 || data.score > 1) {
|
|
1026
|
+
logError('LTI Score Range', 'Score ' + data.score + ' out of range', 'AGS requires scoreGiven / scoreMaximum to be 0-1 normalized.', false);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
saveState();
|
|
1031
|
+
logApiCall('lti.setState', key, 'saved');
|
|
1032
|
+
if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('data');
|
|
1033
|
+
},
|
|
1034
|
+
getLaunchData: function () {
|
|
1035
|
+
return this.launchData;
|
|
1036
|
+
},
|
|
1037
|
+
terminate: function () {
|
|
1038
|
+
checkTerminateCompatibility();
|
|
1039
|
+
saveState();
|
|
1040
|
+
isTerminated = true;
|
|
1041
|
+
this.initialized = false;
|
|
1042
|
+
logApiCall('lti.terminate', null, 'sent');
|
|
1043
|
+
syncToServer();
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// ========================================
|
|
1048
|
+
// Objective & Interaction Handlers
|
|
1049
|
+
// ========================================
|
|
1050
|
+
function handleObjectiveGet(element) {
|
|
1051
|
+
const match = element.match(/cmi\.objectives\.(\d+)\.(.+)/);
|
|
1052
|
+
if (!match) return '';
|
|
1053
|
+
const [, index, prop] = match;
|
|
1054
|
+
const objectives = Object.values(cmiData._objectives || {});
|
|
1055
|
+
return objectives[index]?.[prop] || '';
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function handleObjectiveSet(element, value) {
|
|
1059
|
+
const match = element.match(/cmi\.objectives\.(\d+)\.(.+)/);
|
|
1060
|
+
if (!match) return;
|
|
1061
|
+
const [, index, prop] = match;
|
|
1062
|
+
if (!cmiData._objectives) cmiData._objectives = {};
|
|
1063
|
+
if (prop === 'id') {
|
|
1064
|
+
if (!cmiData._objectives[value]) cmiData._objectives[value] = { id: value };
|
|
1065
|
+
} else {
|
|
1066
|
+
const objectives = Object.values(cmiData._objectives);
|
|
1067
|
+
if (objectives[index]) objectives[index][prop] = value;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function handleInteractionGet(element) {
|
|
1072
|
+
const match = element.match(/cmi\.interactions\.(\d+)\.(.+)/);
|
|
1073
|
+
if (!match) return '';
|
|
1074
|
+
const [, index, prop] = match;
|
|
1075
|
+
return cmiData._interactions?.[index]?.[prop] || '';
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function handleInteractionSet(element, value) {
|
|
1079
|
+
const match = element.match(/cmi\.interactions\.(\d+)\.(.+)/);
|
|
1080
|
+
if (!match) return;
|
|
1081
|
+
const [, index, prop] = match;
|
|
1082
|
+
if (!cmiData._interactions) cmiData._interactions = [];
|
|
1083
|
+
while (cmiData._interactions.length <= index) cmiData._interactions.push({});
|
|
1084
|
+
cmiData._interactions[index][prop] = value;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ========================================
|
|
1088
|
+
// xAPI Logging
|
|
1089
|
+
// ========================================
|
|
1090
|
+
export function logXapiStatement(statement) {
|
|
1091
|
+
xapiLog.unshift({ ...statement, receivedAt: new Date().toLocaleTimeString() });
|
|
1092
|
+
if (xapiLog.length > 100) xapiLog.pop();
|
|
1093
|
+
if (uiCallbacks.onXapiLog) uiCallbacks.onXapiLog();
|
|
1094
|
+
}
|