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