@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 +9 -0
- package/README.md +440 -0
- package/dist/index.js +154 -0
- package/dist/utils.js +41 -0
- package/package.json +61 -0
- package/src/scorm_client.js +482 -0
- package/src/utils.js +82 -0
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
|
+
}
|