@branchtrack/scorm-client 1.0.0-beta.1

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 ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2026 Philip Hutchison
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,440 @@
1
+ # scorm-client
2
+
3
+ A modern, class-based JavaScript client for communicating with SCORM 1.2 and SCORM 2004 Learning Management Systems (LMS).
4
+
5
+ > Based on the original [scorm-api-wrapper](https://github.com/pipwerks/scorm-api-wrapper) by Philip Hutchison.
6
+
7
+ ## Installation
8
+
9
+ Install directly from GitHub:
10
+
11
+ ```bash
12
+ npm install github:branchtrack/scorm-client
13
+ ```
14
+
15
+ Or copy `dist/index.js` directly into your project.
16
+
17
+ ## Quick start
18
+
19
+ ```js
20
+ import ScormClient from 'scorm-client';
21
+
22
+ const client = new ScormClient();
23
+
24
+ client.init();
25
+ const saved = client.resume(); // restore previous session state
26
+ client.score({ raw: 85, min: 0, max: 100 });
27
+ client.status('completed');
28
+ client.suspend({ page: 3 }); // persist state for next session
29
+ client.save();
30
+ client.quit();
31
+ ```
32
+
33
+ ## Constructor
34
+
35
+ ```js
36
+ const client = new ScormClient(options);
37
+ ```
38
+
39
+ | Option | Type | Default | Description |
40
+ | ------------------------ | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
41
+ | `version` | `'1.2' \| '2004' \| null` | `null` | SCORM version to use. When `null`, the client auto-detects by looking for `window.API` (1.2) or `window.API_1484_11` (2004). |
42
+ | `handleCompletionStatus` | `boolean` | `true` | On `init()`, if the current status is `not attempted` (1.2) or `unknown` (2004), automatically sets it to `incomplete`. |
43
+ | `handleExitMode` | `boolean` | `true` | On `quit()`, automatically sets the exit CMI field to `suspend` (incomplete) or `logout`/`normal` (completed/passed). |
44
+ | `handleSessionTime` | `boolean` | `true` | On `quit()`, automatically computes elapsed session time and writes it to the appropriate CMI field before committing. |
45
+ | `debug` | `boolean` | `false` | Logs trace messages to `console.log`. |
46
+
47
+ ```js
48
+ // Auto-detect version (recommended)
49
+ const client = new ScormClient();
50
+
51
+ // Pin to a specific version
52
+ const client = new ScormClient({ version: '2004' });
53
+
54
+ // Debug mode
55
+ const client = new ScormClient({ debug: true });
56
+
57
+ // All options
58
+ const client = new ScormClient({
59
+ version: '1.2',
60
+ handleCompletionStatus: true,
61
+ handleExitMode: true,
62
+ handleSessionTime: true,
63
+ debug: false,
64
+ });
65
+ ```
66
+
67
+ ## Properties
68
+
69
+ | Property | Type | Description |
70
+ | ----------------- | ---------------- | ----------------------------------------------------------------------------- |
71
+ | `client.version` | `string \| null` | Detected or pre-set SCORM version (`'1.2'`, `'2004'`, or `null` before init). |
72
+ | `client.isActive` | `boolean` | Whether the LMS session is currently open. |
73
+
74
+ ```js
75
+ client.init();
76
+ console.log(client.version); // '1.2' or '2004'
77
+ console.log(client.isActive); // true
78
+ ```
79
+
80
+ ## Methods
81
+
82
+ ### `init()` → `boolean`
83
+
84
+ Opens a communication session with the LMS. Returns `true` on success.
85
+
86
+ Must be called before any data operations. Calling `init()` on an already-active connection returns `false` without making an LMS call.
87
+
88
+ ```js
89
+ const ok = client.init();
90
+ if (!ok) {
91
+ console.error('Could not connect to LMS');
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ### `quit()` → `boolean`
98
+
99
+ Closes the LMS session. Returns `true` on success.
100
+
101
+ When `handleExitMode` is enabled (default), automatically sets the exit field before terminating:
102
+
103
+ | Completion status | SCORM 1.2 `cmi.core.exit` | SCORM 2004 `cmi.exit` |
104
+ | ----------------------- | ------------------------- | --------------------- |
105
+ | `completed` or `passed` | `logout` | `normal` |
106
+ | anything else | `suspend` | `suspend` |
107
+
108
+ When `handleSessionTime` is enabled (default), automatically computes the time elapsed since `init()` and writes it to the version-appropriate CMI field before the final commit:
109
+
110
+ | Version | Field | Format | Example |
111
+ | ---------- | ----------------------- | --------------------- | ------------ |
112
+ | SCORM 1.2 | `cmi.core.session_time` | `HH:MM:SS` | `00:45:12` |
113
+ | SCORM 2004 | `cmi.session_time` | ISO 8601 (`PTxHxMxS`) | `PT0H45M12S` |
114
+
115
+ For SCORM 1.2, a `save()` is performed automatically before `LMSFinish`.
116
+
117
+ ```js
118
+ client.quit();
119
+
120
+ // Opt out of automatic session time reporting
121
+ const client = new ScormClient({ handleSessionTime: false });
122
+ ```
123
+
124
+ ---
125
+
126
+ ### `get(parameter)` → `string`
127
+
128
+ Retrieves a value from the LMS data model. Always returns a string (`"null"` if the connection is inactive).
129
+
130
+ Parameters are automatically normalized — see [Field name shortcuts](#field-name-shortcuts) below.
131
+
132
+ ```js
133
+ // Short keys (version-agnostic)
134
+ const status = client.get('lesson_status');
135
+ const score = client.get('score');
136
+ const name = client.get('student_name');
137
+
138
+ // Full CMI paths (passed through unchanged)
139
+ const status = client.get('cmi.core.lesson_status'); // SCORM 1.2
140
+ const status = client.get('cmi.completion_status'); // SCORM 2004
141
+ ```
142
+
143
+ ---
144
+
145
+ ### `set(parameter, value)` → `boolean`
146
+
147
+ Writes a value to the LMS data model. Returns `true` on success.
148
+
149
+ Parameters are automatically normalized — see [Field name shortcuts](#field-name-shortcuts) below.
150
+
151
+ ```js
152
+ // Short keys (version-agnostic)
153
+ client.set('score', '95');
154
+ client.set('lesson_status', 'completed');
155
+ client.set('success_status', 'passed'); // 2004 only
156
+
157
+ // Full CMI paths (passed through unchanged)
158
+ client.set('cmi.core.score.raw', '95'); // SCORM 1.2
159
+ client.set('cmi.completion_status', 'completed'); // SCORM 2004
160
+ ```
161
+
162
+ ---
163
+
164
+ ### `save()` → `boolean`
165
+
166
+ Persists all data to the LMS (calls `LMSCommit` / `Commit`). Returns `true` on success.
167
+
168
+ Useful to checkpoint progress mid-session. For SCORM 1.2 this is required — `quit()` calls it automatically when `handleExitMode` is enabled.
169
+
170
+ ```js
171
+ client.set('cmi.core.score.raw', '72');
172
+ client.save(); // persist now, before the user navigates away
173
+ ```
174
+
175
+ ---
176
+
177
+ ### `status(value?)` → `string | boolean`
178
+
179
+ Shortcut for reading or writing the version-appropriate completion status field.
180
+
181
+ Internally maps to `cmi.core.lesson_status` (1.2) or `cmi.completion_status` (2004).
182
+
183
+ `completion()` is an alias — both methods are identical.
184
+
185
+ - Called with no argument → returns the current status string
186
+ - Called with a string → sets the status, returns `true`/`false`
187
+
188
+ ```js
189
+ const current = client.status(); // returns e.g. 'incomplete'
190
+ client.status('completed');
191
+ client.completion('completed'); // same as above
192
+ ```
193
+
194
+ **Valid status values**
195
+
196
+ | SCORM 1.2 | SCORM 2004 |
197
+ | --------------- | --------------- |
198
+ | `not attempted` | `not attempted` |
199
+ | `incomplete` | `incomplete` |
200
+ | `completed` | `completed` |
201
+ | `passed` | — |
202
+ | `failed` | — |
203
+ | `browsed` | — |
204
+ | — | `unknown` |
205
+
206
+ ---
207
+
208
+ ### `score(value?)` → `string | boolean`
209
+
210
+ Shortcut for reading or writing score fields. Abstracts the version difference between `cmi.core.score.*` (1.2) and `cmi.score.*` (2004).
211
+
212
+ - Called with no argument → returns the raw score string
213
+ - Called with a number → sets `score.raw`, returns `true`/`false`
214
+ - Called with an object → sets each present key (`raw`, `min`, `max`, `scaled`); `scaled` is silently ignored on SCORM 1.2
215
+
216
+ ```js
217
+ const raw = client.score(); // get raw score
218
+ client.score(85); // set raw score
219
+ client.score({ raw: 85, min: 0, max: 100 }); // set multiple fields
220
+ client.score({ raw: 85, min: 0, max: 100, scaled: 0.85 }); // 2004: also sets scaled
221
+ ```
222
+
223
+ ---
224
+
225
+ ### `suspend(data)` → `boolean`
226
+
227
+ Serializes data and writes it to `cmi.suspend_data`. Strings are stored as-is; any other value is `JSON.stringify`'d first.
228
+
229
+ ```js
230
+ client.suspend({ page: 3, answers: [1, 0, 2] }); // stored as JSON string
231
+ client.suspend('raw string'); // stored as-is
232
+ ```
233
+
234
+ ---
235
+
236
+ ### `resume()` → `any | null`
237
+
238
+ Reads `cmi.suspend_data` and attempts to parse it as JSON.
239
+
240
+ - Returns `null` if the field is empty
241
+ - Returns the parsed value if the content is valid JSON
242
+ - Returns the raw string if JSON parsing fails
243
+
244
+ ```js
245
+ client.suspend({ page: 3 });
246
+ // ... later session ...
247
+ const state = client.resume(); // → { page: 3 }
248
+ ```
249
+
250
+ ---
251
+
252
+ ### `location(value?)` → `string | boolean`
253
+
254
+ Shortcut for reading or writing the learner's bookmark position.
255
+
256
+ Maps to `cmi.core.lesson_location` (1.2) or `cmi.location` (2004).
257
+
258
+ - Called with no argument → returns the current location string
259
+ - Called with a string → sets the location, returns `true`/`false`
260
+
261
+ ```js
262
+ const bookmark = client.location(); // get current position
263
+ client.location('slide-12'); // save position
264
+ ```
265
+
266
+ ---
267
+
268
+ ### `success(value?)` → `string | boolean`
269
+
270
+ Shortcut for reading or writing the version-appropriate success status field.
271
+
272
+ Maps to `cmi.core.success_status` (1.2) or `cmi.success_status` (2004).
273
+
274
+ - Called with no argument → returns the current value string (`'passed'`, `'failed'`, or `'unknown'`)
275
+ - Called with `true` → sets `'passed'`
276
+ - Called with `false` → sets `'failed'`
277
+ - Called with a string → sets the value directly
278
+
279
+ ```js
280
+ client.success(true); // sets 'passed'
281
+ client.success(false); // sets 'failed'
282
+ client.success('unknown'); // sets directly
283
+ const result = client.success(); // → 'passed' / 'failed' / 'unknown'
284
+ ```
285
+
286
+ ---
287
+
288
+ ### `getLastError()` → `number`
289
+
290
+ Returns the last LMS error code as an integer. Returns `0` if there is no error or the API is unavailable.
291
+
292
+ ```js
293
+ const code = client.getLastError();
294
+ if (code !== 0) {
295
+ console.error('LMS error code:', code);
296
+ }
297
+ ```
298
+
299
+ ---
300
+
301
+ ### `getErrorInfo(errorCode)` → `string`
302
+
303
+ Returns the human-readable description for a given error code.
304
+
305
+ ```js
306
+ const code = client.getLastError();
307
+ const info = client.getErrorInfo(code);
308
+ console.error(info); // e.g. 'Invalid argument error'
309
+ ```
310
+
311
+ ---
312
+
313
+ ### `getDiagnosticInfo(errorCode)` → `string`
314
+
315
+ Returns LMS-specific diagnostic details for a given error code. Content is LMS-defined.
316
+
317
+ ```js
318
+ const code = client.getLastError();
319
+ console.debug(client.getDiagnosticInfo(code));
320
+ ```
321
+
322
+ ---
323
+
324
+ ### `isAvailable()` → `true`
325
+
326
+ Always returns `true`. Allows external runtimes to confirm the wrapper is loaded.
327
+
328
+ ---
329
+
330
+ ## Typical session lifecycle
331
+
332
+ ```js
333
+ import ScormClient from 'scorm-client';
334
+
335
+ const client = new ScormClient();
336
+
337
+ // 1. Open session
338
+ if (!client.init()) {
339
+ throw new Error('LMS connection failed');
340
+ }
341
+
342
+ // 2. Read learner data
343
+ const studentName = client.get('student_name'); // cmi.core.student_name (1.2) / cmi.learner_name (2004)
344
+ // or equivalently:
345
+ const learnerName = client.get('learner_name'); // same result, both forms accepted
346
+
347
+ // 3. Restore saved state
348
+ const saved = client.resume(); // null on first launch, object on return
349
+
350
+ // 4. Update progress
351
+ client.set('score', '88'); // resolves per version
352
+
353
+ // 5. Mark complete
354
+ client.status('completed');
355
+
356
+ // 6. Save bookmark and close
357
+ client.location('slide-12');
358
+ client.suspend({ page: 5, completed: true });
359
+ client.save();
360
+ client.quit();
361
+ ```
362
+
363
+ ## Field name shortcuts
364
+
365
+ `get()` and `set()` accept short field names that are resolved to the correct CMI path for the active SCORM version. Pass a full `cmi.*` path to bypass normalization entirely.
366
+
367
+ **Resolution rules (applied in order):**
368
+
369
+ 1. Key starts with `cmi.` → passed through unchanged
370
+ 2. Key matches an exception entry → use the mapped path
371
+ 3. Otherwise → prepend the default prefix (`cmi.core.` for 1.2, `cmi.` for 2004)
372
+
373
+ **Exception mappings (keys that deviate from the default prefix):**
374
+
375
+ | Short key | SCORM 1.2 | SCORM 2004 |
376
+ | ----------------- | -------------------------- | ----------------------- |
377
+ | `score` | `cmi.core.score.raw` | `cmi.score.raw` |
378
+ | `lesson_status` | `cmi.core.lesson_status` | `cmi.completion_status` |
379
+ | `lesson_location` | `cmi.core.lesson_location` | `cmi.location` |
380
+ | `location` | `cmi.core.lesson_location` | `cmi.location` |
381
+ | `suspend_data` | `cmi.suspend_data` | `cmi.suspend_data` |
382
+
383
+ **Cross-version learner identity aliases:**
384
+
385
+ SCORM 1.2 uses `student_*` field names; SCORM 2004 uses `learner_*`. Both short forms are accepted in either version and resolve to the correct underlying field.
386
+
387
+ | Short key | SCORM 1.2 | SCORM 2004 |
388
+ | -------------- | ----------------------- | ------------------ |
389
+ | `learner_id` | `cmi.core.student_id` | `cmi.learner_id` |
390
+ | `learner_name` | `cmi.core.student_name` | `cmi.learner_name` |
391
+ | `student_id` | `cmi.core.student_id` | `cmi.learner_id` |
392
+ | `student_name` | `cmi.core.student_name` | `cmi.learner_name` |
393
+
394
+ **Default prefix examples (no exception needed):**
395
+
396
+ ```js
397
+ client.get('success_status'); // → cmi.core.success_status (1.2) / cmi.success_status (2004)
398
+ client.get('completion_threshold'); // → cmi.core.completion_threshold (1.2) / cmi.completion_threshold (2004)
399
+ client.set('exit', 'suspend'); // → cmi.core.exit (1.2) / cmi.exit (2004)
400
+ ```
401
+
402
+ ---
403
+
404
+ ## SCORM 1.2 vs SCORM 2004 field reference
405
+
406
+ | Concept | SCORM 1.2 | SCORM 2004 |
407
+ | ----------------- | -------------------------------------------- | ----------------------- |
408
+ | Completion status | `cmi.core.lesson_status` | `cmi.completion_status` |
409
+ | Success status | `cmi.core.lesson_status` (`passed`/`failed`) | `cmi.success_status` |
410
+ | Score (raw) | `cmi.core.score.raw` | `cmi.score.raw` |
411
+ | Score (min) | `cmi.core.score.min` | `cmi.score.min` |
412
+ | Score (max) | `cmi.core.score.max` | `cmi.score.max` |
413
+ | Session time | `cmi.core.session_time` | `cmi.session_time` |
414
+ | Learner name | `cmi.core.student_name` | `cmi.learner_name` |
415
+ | Learner ID | `cmi.core.student_id` | `cmi.learner_id` |
416
+ | Exit | `cmi.core.exit` | `cmi.exit` |
417
+ | Suspend data | `cmi.suspend_data` | `cmi.suspend_data` |
418
+
419
+ ## Browser support
420
+
421
+ Built with Vite (Oxc) targeting ES2015+. Supports all modern browsers:
422
+
423
+ - Last 2 Chrome versions
424
+ - Last 2 Edge versions
425
+ - Last 2 Firefox versions + Firefox ESR
426
+ - Last 2 Safari versions
427
+ - Last 2 iOS Safari versions
428
+
429
+ ## Development
430
+
431
+ ```bash
432
+ npm install # install dependencies
433
+ npm run build # compile + minify → dist/index.js
434
+ npm test # run tests (Vitest)
435
+ npm run test:watch # watch mode
436
+ ```
437
+
438
+ ## License
439
+
440
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ import { formatSessionTime as e, normalizeField as t, stringToBoolean as n } from "./utils.js";
2
+ //#region src/scorm_client.js
3
+ var r = class {
4
+ #e;
5
+ #t;
6
+ #n;
7
+ #r;
8
+ #i;
9
+ #a;
10
+ #o;
11
+ #s;
12
+ #c;
13
+ #l;
14
+ #u;
15
+ constructor({ version: e = null, handleCompletionStatus: t = !0, handleExitMode: n = !0, handleSessionTime: r = !0, debug: i = !1 } = {}) {
16
+ this.#e = e, this.#t = t, this.#n = n, this.#r = r, this.#i = i, this.#a = null, this.#o = !1, this.#s = !1, this.#c = null, this.#l = null, this.#u = null;
17
+ }
18
+ get version() {
19
+ return this.#e;
20
+ }
21
+ get isActive() {
22
+ return this.#s;
23
+ }
24
+ isAvailable() {
25
+ return !0;
26
+ }
27
+ init() {
28
+ if (this.#s) return this.#m("connection.initialize aborted: Connection already active."), !1;
29
+ let e = this.#p();
30
+ if (!e) return this.#m("connection.initialize failed: API is null."), !1;
31
+ let t = this.#e === "1.2" ? n(e.LMSInitialize("")) : n(e.Initialize(""));
32
+ if (t) {
33
+ let e = this.getLastError();
34
+ if (e === 0) {
35
+ if (this.#s = !0, this.#u = Date.now(), this.#t) {
36
+ let e = this.status();
37
+ (e === "not attempted" || e === "unknown") && (this.status("incomplete"), this.save());
38
+ }
39
+ } else t = !1, this.#m(`connection.initialize failed. Error code: ${e} | Info: ${this.getErrorInfo(e)}`);
40
+ } else {
41
+ let e = this.getLastError();
42
+ this.#m(e && e !== 0 ? `connection.initialize failed. Error code: ${e} | Info: ${this.getErrorInfo(e)}` : "connection.initialize failed: No response from server.");
43
+ }
44
+ return t;
45
+ }
46
+ quit() {
47
+ if (!this.#s) return this.#m("connection.terminate aborted: Connection already terminated."), !1;
48
+ let t = this.#p();
49
+ if (!t) return this.#m("connection.terminate failed: API is null."), !1;
50
+ if (this.#n && !this.#l) {
51
+ let e = this.#c === "completed" || this.#c === "passed";
52
+ this.set("exit", e ? this.#e === "1.2" ? "logout" : "normal" : "suspend");
53
+ }
54
+ if (this.#r && this.#u) {
55
+ let t = Math.round((Date.now() - this.#u) / 1e3);
56
+ this.set("session_time", e(this.#e, t));
57
+ }
58
+ if (!(this.#e !== "1.2" || this.save())) return !1;
59
+ let r = this.#e === "1.2" ? n(t.LMSFinish("")) : n(t.Terminate(""));
60
+ if (r) this.#s = !1, this.#u = null;
61
+ else {
62
+ let e = this.getLastError();
63
+ this.#m(`connection.terminate failed. Error code: ${e} | Info: ${this.getErrorInfo(e)}`);
64
+ }
65
+ return r;
66
+ }
67
+ get(e) {
68
+ if (e = t(this.#e, e), !this.#s) return this.#m(`data.get('${e}') failed: API connection is inactive.`), "null";
69
+ let n = this.#p();
70
+ if (!n) return this.#m(`data.get('${e}') failed: API is null.`), "null";
71
+ let r = this.#e === "1.2" ? n.LMSGetValue(e) : n.GetValue(e), i = this.getLastError();
72
+ return r !== "" || i === 0 ? ((e === "cmi.core.lesson_status" || e === "cmi.completion_status") && (this.#c = r), (e === "cmi.core.exit" || e === "cmi.exit") && (this.#l = r)) : this.#m(`data.get('${e}') failed. Error code: ${i} | Info: ${this.getErrorInfo(i)}`), String(r);
73
+ }
74
+ set(e, r) {
75
+ if (e = t(this.#e, e), !this.#s) return this.#m(`data.set('${e}') failed: API connection is inactive.`), !1;
76
+ let i = this.#p();
77
+ if (!i) return this.#m(`data.set('${e}') failed: API is null.`), !1;
78
+ let a = this.#e === "1.2" ? n(i.LMSSetValue(e, r)) : n(i.SetValue(e, r));
79
+ if (a) (e === "cmi.core.lesson_status" || e === "cmi.completion_status") && (this.#c = r);
80
+ else {
81
+ let t = this.getLastError();
82
+ this.#m(`data.set('${e}') failed. Error code: ${t} | Info: ${this.getErrorInfo(t)}`);
83
+ }
84
+ return a;
85
+ }
86
+ save() {
87
+ if (!this.#s) return this.#m("data.save failed: API connection is inactive."), !1;
88
+ let e = this.#p();
89
+ return e ? this.#e === "1.2" ? n(e.LMSCommit("")) : n(e.Commit("")) : (this.#m("data.save failed: API is null."), !1);
90
+ }
91
+ status(e) {
92
+ return e === void 0 ? this.get("lesson_status") : e ? this.set("lesson_status", e) : (this.#m("status failed: status value was not specified."), !1);
93
+ }
94
+ completion(e) {
95
+ return this.status(e);
96
+ }
97
+ success(e) {
98
+ return e === void 0 ? this.get("success_status") : e === !0 ? this.set("success_status", "passed") : e === !1 ? this.set("success_status", "failed") : this.set("success_status", e);
99
+ }
100
+ location(e) {
101
+ return e === void 0 ? this.get("location") : this.set("location", e);
102
+ }
103
+ score(e) {
104
+ if (e === void 0) return this.get("score");
105
+ if (typeof e == "number") return this.set("score", String(e));
106
+ if (typeof e == "object" && e) {
107
+ let t = !0;
108
+ return e.raw !== void 0 && (t = this.set("score.raw", String(e.raw)) && t), e.min !== void 0 && (t = this.set("score.min", String(e.min)) && t), e.max !== void 0 && (t = this.set("score.max", String(e.max)) && t), e.scaled !== void 0 && this.#e === "2004" && (t = this.set("score.scaled", String(e.scaled)) && t), t;
109
+ }
110
+ return this.#m("score failed: invalid value type."), !1;
111
+ }
112
+ suspend(e) {
113
+ let t = typeof e == "string" ? e : JSON.stringify(e);
114
+ return this.set("suspend_data", t);
115
+ }
116
+ resume() {
117
+ let e = this.get("suspend_data");
118
+ if (!e || e === "null") return null;
119
+ try {
120
+ return JSON.parse(e);
121
+ } catch {
122
+ return e;
123
+ }
124
+ }
125
+ getLastError() {
126
+ let e = this.#p();
127
+ return e ? this.#e === "1.2" ? parseInt(e.LMSGetLastError(), 10) : parseInt(e.GetLastError(), 10) : (this.#m("getLastError failed: API is null."), 0);
128
+ }
129
+ getErrorInfo(e) {
130
+ let t = this.#p();
131
+ return t ? String(this.#e === "1.2" ? t.LMSGetErrorString(String(e)) : t.GetErrorString(String(e))) : (this.#m("getErrorInfo failed: API is null."), "");
132
+ }
133
+ getDiagnosticInfo(e) {
134
+ let t = this.#p();
135
+ return t ? String(this.#e === "1.2" ? t.LMSGetDiagnostic(e) : t.GetDiagnostic(e)) : (this.#m("getDiagnosticInfo failed: API is null."), "");
136
+ }
137
+ #d(e) {
138
+ let t = 0;
139
+ for (; !e.API && !e.API_1484_11 && e.parent && e.parent !== e && t <= 500;) t++, e = e.parent;
140
+ return this.#e ? this.#e === "2004" ? e.API_1484_11 ?? null : this.#e === "1.2" ? e.API ?? null : null : e.API_1484_11 ? (this.#e = "2004", e.API_1484_11) : e.API ? (this.#e = "1.2", e.API) : (this.#m(`API.find: no API found after ${t} attempts.`), null);
141
+ }
142
+ #f() {
143
+ let e = this.#d(window);
144
+ return !e && window.parent && window.parent !== window && (e = this.#d(window.parent)), !e && window.top?.opener && (e = this.#d(window.top.opener)), !e && window.top?.opener?.document && (e = this.#d(window.top.opener.document)), e ? this.#o = !0 : this.#m("API.get: Can't find the API!"), e;
145
+ }
146
+ #p() {
147
+ return !this.#a && !this.#o && (this.#a = this.#f()), this.#a;
148
+ }
149
+ #m(e) {
150
+ this.#i && window.console?.log && window.console.log(e);
151
+ }
152
+ };
153
+ //#endregion
154
+ export { r as ScormClient, r as default };
package/dist/utils.js ADDED
@@ -0,0 +1,41 @@
1
+ //#region src/utils.js
2
+ function e(e, t) {
3
+ if (t.startsWith("cmi.")) return t;
4
+ let n = {
5
+ "1.2": {
6
+ suspend_data: "cmi.suspend_data",
7
+ score: "cmi.core.score.raw",
8
+ location: "cmi.core.lesson_location",
9
+ learner_id: "cmi.core.student_id",
10
+ learner_name: "cmi.core.student_name"
11
+ },
12
+ 2004: {
13
+ score: "cmi.score.raw",
14
+ lesson_status: "cmi.completion_status",
15
+ lesson_location: "cmi.location",
16
+ student_id: "cmi.learner_id",
17
+ student_name: "cmi.learner_name"
18
+ }
19
+ }[e] ?? {};
20
+ return t in n ? n[t] : (e === "1.2" ? "cmi.core." : "cmi.") + t;
21
+ }
22
+ function t(e) {
23
+ switch (typeof e) {
24
+ case "object":
25
+ case "string": return /(true|1)/i.test(e);
26
+ case "number": return !!e;
27
+ case "boolean": return e;
28
+ case "undefined": return null;
29
+ default: return !1;
30
+ }
31
+ }
32
+ function n(e, t) {
33
+ let n = Math.floor(t / 3600), r = Math.floor(t % 3600 / 60), i = t % 60;
34
+ return e === "1.2" ? [
35
+ n,
36
+ r,
37
+ i
38
+ ].map((e) => String(e).padStart(2, "0")).join(":") : `PT${n}H${r}M${i}S`;
39
+ }
40
+ //#endregion
41
+ export { n as formatSessionTime, e as normalizeField, t as stringToBoolean };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@branchtrack/scorm-client",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "The SCORM API Client",
5
+ "keywords": [
6
+ "SCORM",
7
+ "elearning",
8
+ "lms"
9
+ ],
10
+ "homepage": "https://github.com/branchtrack/scorm-client",
11
+ "bugs": {
12
+ "url": "https://github.com/branchtrack/scorm-client/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/branchtrack/scorm-client.git"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Sergey Margaritov (attenzione)",
20
+ "contributors": [
21
+ {
22
+ "name": "Philip Hutchison (pipwerks)",
23
+ "url": "https://github.com/pipwerks/scorm-api-wrapper"
24
+ }
25
+ ],
26
+ "type": "module",
27
+ "exports": {
28
+ ".": "./dist/index.js",
29
+ "./utils": "./dist/utils.js"
30
+ },
31
+ "main": "dist/index.js",
32
+ "directories": {
33
+ "test": "tests"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "build": "vite build --config vite.config.build.js",
43
+ "prepublishOnly": "npm run build",
44
+ "release": "release-it",
45
+ "release:major": "release-it major",
46
+ "release:minor": "release-it minor",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest"
49
+ },
50
+ "devDependencies": {
51
+ "browserslist-to-esbuild": "^2.1.1",
52
+ "chai": "^6.2.2",
53
+ "jsdom": "^29.0.1",
54
+ "release-it": "^19.2.4",
55
+ "vite": "^8.0.3",
56
+ "vitest": "^4.1.2"
57
+ },
58
+ "engines": {
59
+ "node": ">=18"
60
+ }
61
+ }
@@ -0,0 +1,482 @@
1
+ import { normalizeField, stringToBoolean, formatSessionTime } from './utils.js';
2
+
3
+ /*
4
+ ScormClient — modern class-based SCORM 1.2 / 2004 API wrapper
5
+ Replaces the pipwerks singleton with a proper instantiable client.
6
+
7
+ Usage:
8
+ import ScormClient from 'scorm-client';
9
+
10
+ const client = new ScormClient();
11
+ // or pre-set the version and options:
12
+ const client = new ScormClient({ version: '1.2', debug: true });
13
+
14
+ client.init();
15
+ client.set('cmi.core.score.raw', '95');
16
+ client.quit();
17
+ */
18
+
19
+ export class ScormClient {
20
+ // Private state
21
+ #version;
22
+ #handleCompletionStatus;
23
+ #handleExitMode;
24
+ #handleSessionTime;
25
+ #debugActive;
26
+ #apiHandle;
27
+ #apiFound;
28
+ #connectionActive;
29
+ #completionStatus;
30
+ #exitStatus;
31
+ #sessionStartTime;
32
+
33
+ /**
34
+ * @param {object} [options]
35
+ * @param {string|null} [options.version] - Pre-set SCORM version ('1.2' or '2004'). Auto-detected when null.
36
+ * @param {boolean} [options.handleCompletionStatus] - Automatically set 'incomplete' on first launch (default: true).
37
+ * @param {boolean} [options.handleExitMode] - Automatically set exit mode on terminate (default: true).
38
+ * @param {boolean} [options.handleSessionTime] - Automatically write session time on quit (default: true).
39
+ * @param {boolean} [options.debug] - Enable console trace logging (default: false).
40
+ */
41
+ constructor({ version = null, handleCompletionStatus = true, handleExitMode = true, handleSessionTime = true, debug = false } = {}) {
42
+ this.#version = version;
43
+ this.#handleCompletionStatus = handleCompletionStatus;
44
+ this.#handleExitMode = handleExitMode;
45
+ this.#handleSessionTime = handleSessionTime;
46
+ this.#debugActive = debug;
47
+ this.#apiHandle = null;
48
+ this.#apiFound = false;
49
+ this.#connectionActive = false;
50
+ this.#completionStatus = null;
51
+ this.#exitStatus = null;
52
+ this.#sessionStartTime = null;
53
+ }
54
+
55
+ // ── Public read-only accessors ──────────────────────────────────────────── //
56
+
57
+ get version() { return this.#version; }
58
+ get isActive() { return this.#connectionActive; }
59
+
60
+ /** Allows Flash / other runtimes to confirm the wrapper is present. */
61
+ isAvailable() { return true; }
62
+
63
+ // ── Connection ───────────────────────────────────────────────────────────── //
64
+
65
+ /** Initialize the LMS session. Returns true on success. */
66
+ init() {
67
+ if (this.#connectionActive) {
68
+ this.#trace('connection.initialize aborted: Connection already active.');
69
+ return false;
70
+ }
71
+
72
+ const api = this.#getApiHandle();
73
+ if (!api) {
74
+ this.#trace('connection.initialize failed: API is null.');
75
+ return false;
76
+ }
77
+
78
+ let success =
79
+ this.#version === '1.2'
80
+ ? stringToBoolean(api.LMSInitialize(''))
81
+ : stringToBoolean(api.Initialize(''));
82
+
83
+ if (success) {
84
+ const errorCode = this.getLastError();
85
+ if (errorCode === 0) {
86
+ this.#connectionActive = true;
87
+ this.#sessionStartTime = Date.now();
88
+
89
+ if (this.#handleCompletionStatus) {
90
+ const currentStatus = this.status();
91
+ if (currentStatus === 'not attempted' || currentStatus === 'unknown') {
92
+ this.status('incomplete');
93
+ this.save();
94
+ }
95
+ }
96
+ } else {
97
+ success = false;
98
+ this.#trace(`connection.initialize failed. Error code: ${errorCode} | Info: ${this.getErrorInfo(errorCode)}`);
99
+ }
100
+ } else {
101
+ const errorCode = this.getLastError();
102
+ this.#trace(
103
+ errorCode && errorCode !== 0
104
+ ? `connection.initialize failed. Error code: ${errorCode} | Info: ${this.getErrorInfo(errorCode)}`
105
+ : 'connection.initialize failed: No response from server.'
106
+ );
107
+ }
108
+
109
+ return success;
110
+ }
111
+
112
+ /** Terminate the LMS session. Returns true on success. */
113
+ quit() {
114
+ if (!this.#connectionActive) {
115
+ this.#trace('connection.terminate aborted: Connection already terminated.');
116
+ return false;
117
+ }
118
+
119
+ const api = this.#getApiHandle();
120
+ if (!api) {
121
+ this.#trace('connection.terminate failed: API is null.');
122
+ return false;
123
+ }
124
+
125
+ if (this.#handleExitMode && !this.#exitStatus) {
126
+ const finished = this.#completionStatus === 'completed' || this.#completionStatus === 'passed';
127
+ this.set('exit', finished ? (this.#version === '1.2' ? 'logout' : 'normal') : 'suspend');
128
+ }
129
+
130
+ // Write session time before committing.
131
+ if (this.#handleSessionTime && this.#sessionStartTime) {
132
+ const elapsedSeconds = Math.round((Date.now() - this.#sessionStartTime) / 1000);
133
+ this.set('session_time', formatSessionTime(this.#version, elapsedSeconds));
134
+ }
135
+
136
+ // SCORM 1.2 requires an explicit commit before LMSFinish; 2004 commits implicitly on Terminate.
137
+ const saved = this.#version === '1.2' ? this.save() : true;
138
+ if (!saved) return false;
139
+
140
+ const success =
141
+ this.#version === '1.2'
142
+ ? stringToBoolean(api.LMSFinish(''))
143
+ : stringToBoolean(api.Terminate(''));
144
+
145
+ if (success) {
146
+ this.#connectionActive = false;
147
+ this.#sessionStartTime = null;
148
+ } else {
149
+ const errorCode = this.getLastError();
150
+ this.#trace(`connection.terminate failed. Error code: ${errorCode} | Info: ${this.getErrorInfo(errorCode)}`);
151
+ }
152
+
153
+ return success;
154
+ }
155
+
156
+ // ── Data ─────────────────────────────────────────────────────────────────── //
157
+
158
+ /** Get a SCORM data model value. Returns a string. */
159
+ get(parameter) {
160
+ parameter = normalizeField(this.#version, parameter);
161
+
162
+ if (!this.#connectionActive) {
163
+ this.#trace(`data.get('${parameter}') failed: API connection is inactive.`);
164
+ return String(null);
165
+ }
166
+
167
+ const api = this.#getApiHandle();
168
+ if (!api) {
169
+ this.#trace(`data.get('${parameter}') failed: API is null.`);
170
+ return String(null);
171
+ }
172
+
173
+ const value =
174
+ this.#version === '1.2'
175
+ ? api.LMSGetValue(parameter)
176
+ : api.GetValue(parameter);
177
+
178
+ const errorCode = this.getLastError();
179
+ if (value !== '' || errorCode === 0) {
180
+ if (parameter === 'cmi.core.lesson_status' || parameter === 'cmi.completion_status') {
181
+ this.#completionStatus = value;
182
+ }
183
+ if (parameter === 'cmi.core.exit' || parameter === 'cmi.exit') {
184
+ this.#exitStatus = value;
185
+ }
186
+ } else {
187
+ this.#trace(`data.get('${parameter}') failed. Error code: ${errorCode} | Info: ${this.getErrorInfo(errorCode)}`);
188
+ }
189
+
190
+ return String(value);
191
+ }
192
+
193
+ /** Set a SCORM data model value. Returns true on success. */
194
+ set(parameter, value) {
195
+ parameter = normalizeField(this.#version, parameter);
196
+
197
+ if (!this.#connectionActive) {
198
+ this.#trace(`data.set('${parameter}') failed: API connection is inactive.`);
199
+ return false;
200
+ }
201
+
202
+ const api = this.#getApiHandle();
203
+ if (!api) {
204
+ this.#trace(`data.set('${parameter}') failed: API is null.`);
205
+ return false;
206
+ }
207
+
208
+ const success =
209
+ this.#version === '1.2'
210
+ ? stringToBoolean(api.LMSSetValue(parameter, value))
211
+ : stringToBoolean(api.SetValue(parameter, value));
212
+
213
+ if (success) {
214
+ if (parameter === 'cmi.core.lesson_status' || parameter === 'cmi.completion_status') {
215
+ this.#completionStatus = value;
216
+ }
217
+ } else {
218
+ const errorCode = this.getLastError();
219
+ this.#trace(`data.set('${parameter}') failed. Error code: ${errorCode} | Info: ${this.getErrorInfo(errorCode)}`);
220
+ }
221
+
222
+ return success;
223
+ }
224
+
225
+ /** Persist all data to the LMS. Returns true on success. */
226
+ save() {
227
+ if (!this.#connectionActive) {
228
+ this.#trace('data.save failed: API connection is inactive.');
229
+ return false;
230
+ }
231
+
232
+ const api = this.#getApiHandle();
233
+ if (!api) {
234
+ this.#trace('data.save failed: API is null.');
235
+ return false;
236
+ }
237
+
238
+ return this.#version === '1.2'
239
+ ? stringToBoolean(api.LMSCommit(''))
240
+ : stringToBoolean(api.Commit(''));
241
+ }
242
+
243
+ // ── Status shortcut ──────────────────────────────────────────────────────── //
244
+
245
+ /**
246
+ * Get or set the SCORM completion status.
247
+ * @param {'get'|'set'} action
248
+ * @param {string} [value] - Required when action is 'set'.
249
+ */
250
+ status(value) {
251
+ if (value === undefined) return this.get('lesson_status');
252
+
253
+ if (!value) {
254
+ this.#trace('status failed: status value was not specified.');
255
+ return false;
256
+ }
257
+
258
+ return this.set('lesson_status', value);
259
+ }
260
+
261
+ /** Alias for {@link status}. */
262
+ completion(value) { return this.status(value); }
263
+
264
+ // ── Success shortcut ─────────────────────────────────────────────────────── //
265
+
266
+ /**
267
+ * Get or set the SCORM success status.
268
+ * Maps to cmi.core.success_status (1.2) or cmi.success_status (2004).
269
+ *
270
+ * - Called with no argument → returns the current value string
271
+ * - Called with true/false → sets 'passed' / 'failed'
272
+ * - Called with a string → sets the value directly
273
+ *
274
+ * @param {boolean|string} [value]
275
+ * @returns {string|boolean}
276
+ */
277
+ success(value) {
278
+ if (value === undefined) return this.get('success_status');
279
+ if (value === true) return this.set('success_status', 'passed');
280
+ if (value === false) return this.set('success_status', 'failed');
281
+
282
+ return this.set('success_status', value);
283
+ }
284
+
285
+ // ── Location shortcut ────────────────────────────────────────────────────── //
286
+
287
+ /**
288
+ * Get or set the learner's bookmark location.
289
+ * Maps to cmi.core.lesson_location (1.2) or cmi.location (2004).
290
+ * @param {string} [value]
291
+ * @returns {string|boolean}
292
+ */
293
+ location(value) {
294
+ if (value === undefined) return this.get('location');
295
+
296
+ return this.set('location', value);
297
+ }
298
+
299
+ // ── Score shortcut ──────────────────────────────────────────────────────── //
300
+
301
+ /**
302
+ * Get or set SCORM score fields.
303
+ *
304
+ * @overload
305
+ * @returns {string} Raw score value.
306
+ *
307
+ * @overload
308
+ * @param {number} value - Sets score.raw.
309
+ * @returns {boolean}
310
+ *
311
+ * @overload
312
+ * @param {{ raw?: number, min?: number, max?: number, scaled?: number }} value
313
+ * Sets each present key. `scaled` is silently ignored on SCORM 1.2.
314
+ * @returns {boolean}
315
+ */
316
+ score(value) {
317
+ if (value === undefined) {
318
+ return this.get('score');
319
+ }
320
+
321
+ if (typeof value === 'number') {
322
+ return this.set('score', String(value));
323
+ }
324
+
325
+ if (typeof value === 'object' && value !== null) {
326
+ let success = true;
327
+ if (value.raw !== undefined) success = this.set('score.raw', String(value.raw)) && success;
328
+ if (value.min !== undefined) success = this.set('score.min', String(value.min)) && success;
329
+ if (value.max !== undefined) success = this.set('score.max', String(value.max)) && success;
330
+ if (value.scaled !== undefined && this.#version === '2004') {
331
+ success = this.set('score.scaled', String(value.scaled)) && success;
332
+ }
333
+ return success;
334
+ }
335
+
336
+ this.#trace('score failed: invalid value type.');
337
+
338
+ return false;
339
+ }
340
+
341
+ // ── Suspend data helpers ─────────────────────────────────────────────────── //
342
+
343
+ /**
344
+ * Serialize and store data in cmi.suspend_data.
345
+ * @param {string|object} data - String stored as-is; anything else is JSON.stringify'd.
346
+ * @returns {boolean}
347
+ */
348
+ suspend(data) {
349
+ const raw = typeof data === 'string' ? data : JSON.stringify(data);
350
+
351
+ return this.set('suspend_data', raw);
352
+ }
353
+
354
+ /**
355
+ * Retrieve and deserialize cmi.suspend_data.
356
+ * @returns {any|null} Parsed JSON if possible, raw string if not, null if empty.
357
+ */
358
+ resume() {
359
+ const raw = this.get('suspend_data');
360
+
361
+ if (!raw || raw === 'null') return null;
362
+
363
+ try {
364
+ return JSON.parse(raw);
365
+ } catch {
366
+ return raw;
367
+ }
368
+ }
369
+
370
+ // ── Debug ────────────────────────────────────────────────────────────────── //
371
+
372
+ /** Returns the last LMS error code as an integer. */
373
+ getLastError() {
374
+ const api = this.#getApiHandle();
375
+ if (!api) {
376
+ this.#trace('getLastError failed: API is null.');
377
+ return 0;
378
+ }
379
+
380
+ return this.#version === '1.2'
381
+ ? parseInt(api.LMSGetLastError(), 10)
382
+ : parseInt(api.GetLastError(), 10);
383
+ }
384
+
385
+ /** Returns the error string for a given error code. */
386
+ getErrorInfo(errorCode) {
387
+ const api = this.#getApiHandle();
388
+ if (!api) {
389
+ this.#trace('getErrorInfo failed: API is null.');
390
+ return '';
391
+ }
392
+
393
+ return String(
394
+ this.#version === '1.2'
395
+ ? api.LMSGetErrorString(String(errorCode))
396
+ : api.GetErrorString(String(errorCode))
397
+ );
398
+ }
399
+
400
+ /** Returns LMS-specific diagnostic info for a given error code. */
401
+ getDiagnosticInfo(errorCode) {
402
+ const api = this.#getApiHandle();
403
+ if (!api) {
404
+ this.#trace('getDiagnosticInfo failed: API is null.');
405
+ return '';
406
+ }
407
+
408
+ return String(
409
+ this.#version === '1.2'
410
+ ? api.LMSGetDiagnostic(errorCode)
411
+ : api.GetDiagnostic(errorCode)
412
+ );
413
+ }
414
+
415
+ // ── Private: API discovery ───────────────────────────────────────────────── //
416
+
417
+ #findApi(win) {
418
+ let attempts = 0;
419
+ const limit = 500;
420
+
421
+ while (
422
+ !win.API && !win.API_1484_11 &&
423
+ win.parent && win.parent !== win &&
424
+ attempts <= limit
425
+ ) {
426
+ attempts++;
427
+ win = win.parent;
428
+ }
429
+
430
+ if (this.#version) {
431
+ if (this.#version === '2004') return win.API_1484_11 ?? null;
432
+ if (this.#version === '1.2') return win.API ?? null;
433
+ return null;
434
+ }
435
+
436
+ if (win.API_1484_11) { this.#version = '2004'; return win.API_1484_11; }
437
+ if (win.API) { this.#version = '1.2'; return win.API; }
438
+
439
+ this.#trace(`API.find: no API found after ${attempts} attempts.`);
440
+
441
+ return null;
442
+ }
443
+
444
+ #getApi() {
445
+ let api = this.#findApi(window);
446
+
447
+ if (!api && window.parent && window.parent !== window) {
448
+ api = this.#findApi(window.parent);
449
+ }
450
+ if (!api && window.top?.opener) {
451
+ api = this.#findApi(window.top.opener);
452
+ }
453
+ if (!api && window.top?.opener?.document) {
454
+ api = this.#findApi(window.top.opener.document);
455
+ }
456
+
457
+ if (api) {
458
+ this.#apiFound = true;
459
+ } else {
460
+ this.#trace("API.get: Can't find the API!");
461
+ }
462
+
463
+ return api;
464
+ }
465
+
466
+ #getApiHandle() {
467
+ if (!this.#apiHandle && !this.#apiFound) {
468
+ this.#apiHandle = this.#getApi();
469
+ }
470
+ return this.#apiHandle;
471
+ }
472
+
473
+
474
+
475
+ #trace(msg) {
476
+ if (this.#debugActive && window.console?.log) {
477
+ window.console.log(msg);
478
+ }
479
+ }
480
+ }
481
+
482
+ export default ScormClient;
package/src/utils.js ADDED
@@ -0,0 +1,82 @@
1
+ // ── Field normalisation ───────────────────────────────────────────────────── //
2
+
3
+ /**
4
+ * Resolve a short field name (e.g. 'score', 'lesson_status') to its full
5
+ * CMI path for the given SCORM version.
6
+ *
7
+ * Rules:
8
+ * - If key already starts with 'cmi.' it is returned unchanged.
9
+ * - A small exceptions map covers fields that deviate from the default prefix.
10
+ * - Everything else gets the default prefix: 'cmi.core.' for 1.2, 'cmi.' for 2004.
11
+ *
12
+ * @param {string} version - '1.2' or '2004'
13
+ * @param {string} key
14
+ * @returns {string}
15
+ */
16
+ export function normalizeField(version, key) {
17
+ if (key.startsWith('cmi.')) return key;
18
+
19
+ const exceptions = {
20
+ '1.2': {
21
+ suspend_data: 'cmi.suspend_data',
22
+ score: 'cmi.core.score.raw',
23
+ location: 'cmi.core.lesson_location',
24
+ learner_id: 'cmi.core.student_id',
25
+ learner_name: 'cmi.core.student_name',
26
+ },
27
+ '2004': {
28
+ score: 'cmi.score.raw',
29
+ lesson_status: 'cmi.completion_status',
30
+ lesson_location: 'cmi.location',
31
+ student_id: 'cmi.learner_id',
32
+ student_name: 'cmi.learner_name',
33
+ },
34
+ };
35
+
36
+ const map = exceptions[version] ?? {};
37
+ if (key in map) return map[key];
38
+
39
+ const prefix = version === '1.2' ? 'cmi.core.' : 'cmi.';
40
+ return prefix + key;
41
+ }
42
+
43
+ // ── Type coercion ─────────────────────────────────────────────────────────── //
44
+
45
+ /**
46
+ * Convert various LMS response types to a boolean.
47
+ *
48
+ * @param {*} value
49
+ * @returns {boolean|null}
50
+ */
51
+ export function stringToBoolean(value) {
52
+ switch (typeof value) {
53
+ case 'object':
54
+ case 'string': return /(true|1)/i.test(value);
55
+ case 'number': return !!value;
56
+ case 'boolean': return value;
57
+ case 'undefined': return null;
58
+ default: return false;
59
+ }
60
+ }
61
+
62
+ // ── Session time formatting ───────────────────────────────────────────────── //
63
+
64
+ /**
65
+ * Format elapsed seconds as a SCORM session time string.
66
+ *
67
+ * - SCORM 1.2 → HH:MM:SS
68
+ * - SCORM 2004 → PTxHxMxS
69
+ *
70
+ * @param {string} version - '1.2' or '2004'
71
+ * @param {number} totalSeconds
72
+ * @returns {string}
73
+ */
74
+ export function formatSessionTime(version, totalSeconds) {
75
+ const h = Math.floor(totalSeconds / 3600);
76
+ const m = Math.floor((totalSeconds % 3600) / 60);
77
+ const s = totalSeconds % 60;
78
+ if (version === '1.2') {
79
+ return [h, m, s].map(v => String(v).padStart(2, '0')).join(':');
80
+ }
81
+ return `PT${h}H${m}M${s}S`;
82
+ }