tessera-learn 0.0.1 → 0.0.2
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/AGENTS.md +93 -75
- package/README.md +11 -0
- package/dist/plugin/index.js +79 -78
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +19 -69
- package/src/components/LockedBanner.svelte +30 -0
- package/src/components/Matching.svelte +44 -80
- package/src/components/MultipleChoice.svelte +14 -43
- package/src/components/Quiz.svelte +69 -263
- package/src/components/ResultIcon.svelte +13 -0
- package/src/components/RetryButton.svelte +25 -0
- package/src/components/Sorting.svelte +33 -76
- package/src/components/util.ts +10 -0
- package/src/plugin/export.ts +39 -33
- package/src/plugin/manifest.ts +38 -12
- package/src/plugin/validation.ts +36 -69
- package/src/runtime/App.svelte +15 -20
- package/src/runtime/ErrorPage.svelte +1 -1
- package/src/runtime/adapters/retry.ts +48 -41
- package/src/runtime/adapters/scorm-base.ts +143 -0
- package/src/runtime/adapters/scorm12.ts +37 -117
- package/src/runtime/adapters/scorm2004.ts +34 -115
- package/src/runtime/hooks.svelte.ts +63 -29
- package/src/runtime/xapi/client.ts +2 -2
- package/src/runtime/xapi/publisher.ts +15 -6
- package/src/runtime/xapi/setup.ts +8 -15
- package/styles/layout.css +21 -10
- package/styles/theme.css +4 -0
package/src/runtime/App.svelte
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
const nav = new NavigationState(manifest, progress, config);
|
|
30
30
|
let duration = $state(new DurationTracker(0));
|
|
31
31
|
|
|
32
|
+
const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
|
|
33
|
+
|
|
32
34
|
// Page loading state
|
|
33
35
|
let PageComponent = $state(null);
|
|
34
36
|
let pageLoading = $state(true);
|
|
@@ -125,21 +127,18 @@
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
// ---- Branding ----
|
|
128
|
-
function parseColor(
|
|
129
|
-
|
|
130
|
-
ctx.fillStyle = color;
|
|
131
|
-
if (ctx.fillStyle === '#000000'
|
|
132
|
-
&& color.trim().toLowerCase() !== '#000000'
|
|
133
|
-
&& color.trim().toLowerCase() !== '#000'
|
|
134
|
-
&& color.trim().toLowerCase() !== 'black') {
|
|
130
|
+
function parseColor(color) {
|
|
131
|
+
if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
|
|
135
132
|
return null;
|
|
136
133
|
}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
134
|
+
const el = document.createElement('span');
|
|
135
|
+
el.style.color = color;
|
|
136
|
+
document.documentElement.appendChild(el);
|
|
137
|
+
const computed = getComputedStyle(el).color;
|
|
138
|
+
el.remove();
|
|
139
|
+
const match = computed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
140
|
+
if (!match) return null;
|
|
141
|
+
return { r: +match[1], g: +match[2], b: +match[3] };
|
|
143
142
|
}
|
|
144
143
|
|
|
145
144
|
function rgbToHsl(r, g, b) {
|
|
@@ -160,11 +159,7 @@
|
|
|
160
159
|
const el = document.documentElement;
|
|
161
160
|
if (cfg.branding?.primaryColor) {
|
|
162
161
|
el.style.setProperty('--tessera-primary', cfg.branding.primaryColor);
|
|
163
|
-
|
|
164
|
-
// allocating a new element for every color resolved.
|
|
165
|
-
const canvas = document.createElement('canvas');
|
|
166
|
-
const ctx = canvas.getContext('2d');
|
|
167
|
-
const rgb = ctx ? parseColor(ctx, cfg.branding.primaryColor) : null;
|
|
162
|
+
const rgb = parseColor(cfg.branding.primaryColor);
|
|
168
163
|
if (rgb) {
|
|
169
164
|
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
|
170
165
|
el.style.setProperty('--tessera-primary-light', `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, 90%)`);
|
|
@@ -297,14 +292,14 @@
|
|
|
297
292
|
$effect(() => {
|
|
298
293
|
const scores = progress.quizScores;
|
|
299
294
|
if (!persistenceReady || scores.size === 0) return;
|
|
295
|
+
if (gradedQuizIndices.length === 0) return;
|
|
300
296
|
|
|
301
|
-
const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
|
|
302
297
|
const completedGraded = gradedQuizIndices.filter(i => scores.has(i));
|
|
303
298
|
if (completedGraded.length === 0) return;
|
|
304
299
|
|
|
305
300
|
// Divide by total graded count — incomplete quizzes count as 0, matching
|
|
306
301
|
// the recalculateSuccess logic in progress.svelte.ts.
|
|
307
|
-
const average = completedGraded.reduce((sum, i) => sum + scores.get(i), 0) / gradedQuizIndices.length;
|
|
302
|
+
const average = completedGraded.reduce((sum, i) => sum + (scores.get(i) ?? 0), 0) / gradedQuizIndices.length;
|
|
308
303
|
|
|
309
304
|
untrack(() => {
|
|
310
305
|
adapter.setScore(Math.round(average));
|
|
@@ -10,6 +10,42 @@ export interface LMSErrorReporter {
|
|
|
10
10
|
message(code: string): string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Default attempt count for LMS retry loops (one initial + two retries). */
|
|
14
|
+
export const RETRY_ATTEMPTS = 3;
|
|
15
|
+
|
|
16
|
+
/** Exponential backoff (0-indexed): 100, 200, 400, … ms. */
|
|
17
|
+
export function backoffMs(attempt: number): number {
|
|
18
|
+
return 100 * Math.pow(2, attempt);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// SCORM SetValue may return string "false" or boolean false; everything else is success.
|
|
22
|
+
function lmsCallSucceeded(result: unknown): boolean {
|
|
23
|
+
return result !== false && result !== 'false';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readLastErrorCode(reporter: LMSErrorReporter | undefined): string {
|
|
27
|
+
if (!reporter) return '';
|
|
28
|
+
try { return reporter.code(); } catch { return ''; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function logRetryGiveUp(
|
|
32
|
+
errorReporter: LMSErrorReporter | undefined,
|
|
33
|
+
lastErrCode: string,
|
|
34
|
+
context: string | undefined
|
|
35
|
+
): void {
|
|
36
|
+
let detail = '';
|
|
37
|
+
if (errorReporter && lastErrCode && lastErrCode !== '0') {
|
|
38
|
+
try {
|
|
39
|
+
const msg = errorReporter.message(lastErrCode);
|
|
40
|
+
detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
const ctx = context ? ` [${context}]` : '';
|
|
44
|
+
console.warn(
|
|
45
|
+
`Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
13
49
|
/**
|
|
14
50
|
* Retry wrapper for LMS API calls.
|
|
15
51
|
* Retries up to maxRetries times with exponential backoff.
|
|
@@ -27,36 +63,23 @@ export interface LMSErrorReporter {
|
|
|
27
63
|
*/
|
|
28
64
|
export async function withRetry(
|
|
29
65
|
fn: () => any,
|
|
30
|
-
maxRetries =
|
|
66
|
+
maxRetries = RETRY_ATTEMPTS,
|
|
31
67
|
errorReporter?: LMSErrorReporter,
|
|
32
68
|
context?: string
|
|
33
69
|
): Promise<boolean> {
|
|
34
70
|
let lastErrCode = '';
|
|
35
71
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
36
72
|
try {
|
|
37
|
-
|
|
38
|
-
if (result !== false && result !== 'false') return true;
|
|
73
|
+
if (lmsCallSucceeded(fn())) return true;
|
|
39
74
|
} catch {
|
|
40
75
|
// API call threw — treat as failure
|
|
41
76
|
}
|
|
42
|
-
|
|
43
|
-
try { lastErrCode = errorReporter.code(); } catch {}
|
|
44
|
-
}
|
|
77
|
+
lastErrCode = readLastErrorCode(errorReporter);
|
|
45
78
|
if (attempt < maxRetries - 1) {
|
|
46
|
-
await new Promise((r) => setTimeout(r,
|
|
79
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
47
80
|
}
|
|
48
81
|
}
|
|
49
|
-
|
|
50
|
-
if (errorReporter && lastErrCode && lastErrCode !== '0') {
|
|
51
|
-
try {
|
|
52
|
-
const msg = errorReporter.message(lastErrCode);
|
|
53
|
-
detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
|
|
54
|
-
} catch {}
|
|
55
|
-
}
|
|
56
|
-
const ctx = context ? ` [${context}]` : '';
|
|
57
|
-
console.warn(
|
|
58
|
-
`Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
|
|
59
|
-
);
|
|
82
|
+
logRetryGiveUp(errorReporter, lastErrCode, context);
|
|
60
83
|
return false;
|
|
61
84
|
}
|
|
62
85
|
|
|
@@ -66,8 +89,7 @@ export async function withRetry(
|
|
|
66
89
|
*/
|
|
67
90
|
export function callSync(fn: () => any): boolean {
|
|
68
91
|
try {
|
|
69
|
-
|
|
70
|
-
return result !== false && result !== 'false';
|
|
92
|
+
return lmsCallSucceeded(fn());
|
|
71
93
|
} catch {
|
|
72
94
|
return false;
|
|
73
95
|
}
|
|
@@ -122,8 +144,6 @@ export class WriteQueue {
|
|
|
122
144
|
this.#flushing = true;
|
|
123
145
|
this.#aborted = false;
|
|
124
146
|
|
|
125
|
-
const MAX_ATTEMPTS = 3;
|
|
126
|
-
|
|
127
147
|
while (this.#queue.length > 0) {
|
|
128
148
|
if (this.#aborted) {
|
|
129
149
|
this.#flushing = false;
|
|
@@ -134,11 +154,10 @@ export class WriteQueue {
|
|
|
134
154
|
let succeeded = false;
|
|
135
155
|
let lastErrCode = '';
|
|
136
156
|
|
|
137
|
-
for (let attempt = 0; attempt <
|
|
157
|
+
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
|
|
138
158
|
let ok = false;
|
|
139
159
|
try {
|
|
140
|
-
|
|
141
|
-
ok = result !== false && result !== 'false';
|
|
160
|
+
ok = lmsCallSucceeded(entry.fn());
|
|
142
161
|
} catch {
|
|
143
162
|
// API call threw — treat as failure
|
|
144
163
|
}
|
|
@@ -146,15 +165,13 @@ export class WriteQueue {
|
|
|
146
165
|
succeeded = true;
|
|
147
166
|
break;
|
|
148
167
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
if (attempt < MAX_ATTEMPTS - 1) {
|
|
168
|
+
lastErrCode = readLastErrorCode(this.errorReporter);
|
|
169
|
+
if (attempt < RETRY_ATTEMPTS - 1) {
|
|
153
170
|
// The next attempt is gated on a backoff timer that won't fire
|
|
154
171
|
// during page unload. Mark in-flight so drainSync can re-run
|
|
155
172
|
// the entry synchronously if it interrupts here.
|
|
156
173
|
this.#inFlight = entry;
|
|
157
|
-
await new Promise((r) => setTimeout(r,
|
|
174
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
158
175
|
this.#inFlight = null;
|
|
159
176
|
if (this.#aborted) {
|
|
160
177
|
this.#flushing = false;
|
|
@@ -164,17 +181,7 @@ export class WriteQueue {
|
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
if (!succeeded) {
|
|
167
|
-
|
|
168
|
-
if (this.errorReporter && lastErrCode && lastErrCode !== '0') {
|
|
169
|
-
try {
|
|
170
|
-
const msg = this.errorReporter.message(lastErrCode);
|
|
171
|
-
detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
|
|
172
|
-
} catch {}
|
|
173
|
-
}
|
|
174
|
-
const ctx = entry.context ? ` [${entry.context}]` : '';
|
|
175
|
-
console.warn(
|
|
176
|
-
`Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
|
|
177
|
-
);
|
|
184
|
+
logRetryGiveUp(this.errorReporter, lastErrCode, entry.context);
|
|
178
185
|
this.#queue.unshift(entry);
|
|
179
186
|
this.#flushing = false;
|
|
180
187
|
return;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
|
+
import type { Interaction } from '../interaction.js';
|
|
3
|
+
import { buildScormInteractionFields } from '../interaction-format.js';
|
|
4
|
+
import { WriteQueue, callSync, withRetry } from './retry.js';
|
|
5
|
+
|
|
6
|
+
/** Per-version differences shared between SCORM 1.2 and SCORM 2004 adapters. */
|
|
7
|
+
export interface ScormDialect<TApi> {
|
|
8
|
+
/** SCORM 1.2: `cmi.core.session_time`. SCORM 2004: `cmi.session_time`. */
|
|
9
|
+
sessionTimeKey: string;
|
|
10
|
+
/** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
|
|
11
|
+
formatDuration(seconds: number): string;
|
|
12
|
+
/** Per-interaction-row field config passed to `buildScormInteractionFields`. */
|
|
13
|
+
interactionFields: {
|
|
14
|
+
responseField: 'student_response' | 'learner_response';
|
|
15
|
+
timestampField: 'time' | 'timestamp';
|
|
16
|
+
/** Build the per-call timestamp string (HH:MM:SS for 1.2, ISO8601 for 2004). */
|
|
17
|
+
timestamp(): string;
|
|
18
|
+
typeValue(type: Interaction['type']): string;
|
|
19
|
+
resultLabels: { correct: string; incorrect: string };
|
|
20
|
+
};
|
|
21
|
+
/** API method wrappers — abstract over the `LMS*`-prefixed and bare names. */
|
|
22
|
+
initialize(api: TApi): string;
|
|
23
|
+
terminate(api: TApi): string;
|
|
24
|
+
getValue(api: TApi, key: string): string;
|
|
25
|
+
setValue(api: TApi, key: string, value: string): string;
|
|
26
|
+
commit(api: TApi): string;
|
|
27
|
+
getLastError(api: TApi): string;
|
|
28
|
+
getErrorString(api: TApi, code: string): string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
32
|
+
protected readonly api: TApi;
|
|
33
|
+
protected readonly dialect: ScormDialect<TApi>;
|
|
34
|
+
protected readonly queue = new WriteQueue();
|
|
35
|
+
#state: SavedState | null = null;
|
|
36
|
+
#terminated = false;
|
|
37
|
+
protected interactionCount = 0;
|
|
38
|
+
|
|
39
|
+
constructor(api: TApi, dialect: ScormDialect<TApi>) {
|
|
40
|
+
this.api = api;
|
|
41
|
+
this.dialect = dialect;
|
|
42
|
+
// Wire up GetLastError/GetErrorString so retry warnings can name the
|
|
43
|
+
// real LMS failure (e.g. "201 Invalid argument error") instead of a
|
|
44
|
+
// generic "LMS call failed" — production triage needs the code.
|
|
45
|
+
this.queue.errorReporter = {
|
|
46
|
+
code: () => this.dialect.getLastError(this.api),
|
|
47
|
+
message: (c) => this.dialect.getErrorString(this.api, c),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Expose the underlying SCORM API so xAPI actor synthesis can read learner fields. */
|
|
52
|
+
getAPI(): TApi {
|
|
53
|
+
return this.api;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async init(): Promise<void> {
|
|
57
|
+
await withRetry(() => this.dialect.initialize(this.api));
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const raw = this.dialect.getValue(this.api, 'cmi.suspend_data');
|
|
61
|
+
if (raw && raw.trim()) {
|
|
62
|
+
this.#state = JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
this.#state = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Continue cmi.interactions.n indexing where the previous session left
|
|
69
|
+
// off. Restarting at 0 would overwrite prior records (the LMS uses n
|
|
70
|
+
// as the array key, not an upsert field).
|
|
71
|
+
try {
|
|
72
|
+
const count = this.dialect.getValue(this.api, 'cmi.interactions._count');
|
|
73
|
+
const n = parseInt(count, 10);
|
|
74
|
+
if (Number.isFinite(n) && n >= 0) this.interactionCount = n;
|
|
75
|
+
} catch {
|
|
76
|
+
// Some LMSes throw on _count when no interactions exist — fall back to 0.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getState(): SavedState | null {
|
|
81
|
+
return this.#state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
saveState(state: SavedState): void {
|
|
85
|
+
this.#state = state;
|
|
86
|
+
const json = JSON.stringify(state);
|
|
87
|
+
this.queue.enqueue(() =>
|
|
88
|
+
this.dialect.setValue(this.api, 'cmi.suspend_data', json)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setDuration(seconds: number): void {
|
|
93
|
+
const formatted = this.dialect.formatDuration(seconds);
|
|
94
|
+
this.queue.enqueue(() =>
|
|
95
|
+
this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
reportInteraction(
|
|
100
|
+
questionId: string,
|
|
101
|
+
interaction: Interaction,
|
|
102
|
+
correct: boolean | null
|
|
103
|
+
): void {
|
|
104
|
+
const n = this.interactionCount++;
|
|
105
|
+
const fields = buildScormInteractionFields(
|
|
106
|
+
`cmi.interactions.${n}`,
|
|
107
|
+
questionId,
|
|
108
|
+
interaction,
|
|
109
|
+
correct,
|
|
110
|
+
{
|
|
111
|
+
responseField: this.dialect.interactionFields.responseField,
|
|
112
|
+
timestampField: this.dialect.interactionFields.timestampField,
|
|
113
|
+
timestamp: this.dialect.interactionFields.timestamp(),
|
|
114
|
+
typeValue: this.dialect.interactionFields.typeValue(interaction.type),
|
|
115
|
+
resultLabels: this.dialect.interactionFields.resultLabels,
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
for (const [key, value] of fields) {
|
|
119
|
+
this.queue.enqueue(() => this.dialect.setValue(this.api, key, value));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
commit(): void {
|
|
124
|
+
this.queue.enqueue(() => this.dialect.commit(this.api));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
terminate(): void {
|
|
128
|
+
if (this.#terminated) return;
|
|
129
|
+
this.#terminated = true;
|
|
130
|
+
// During page unload, async retries can't run.
|
|
131
|
+
// Drain any pending queue operations synchronously (single attempt each),
|
|
132
|
+
// then commit and finish synchronously.
|
|
133
|
+
this.queue.drainSync();
|
|
134
|
+
callSync(() => this.dialect.commit(this.api));
|
|
135
|
+
callSync(() => this.dialect.terminate(this.api));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The four operations that genuinely diverge between SCORM versions.
|
|
139
|
+
abstract setScore(score: number): void;
|
|
140
|
+
abstract setCompletionStatus(status: 'incomplete' | 'complete'): void;
|
|
141
|
+
abstract setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void;
|
|
142
|
+
abstract setExit(mode: 'suspend' | 'normal'): void;
|
|
143
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type
|
|
3
|
-
import {
|
|
4
|
-
import { WriteQueue, callSync, withRetry, formatHHMMSS } from './retry.js';
|
|
1
|
+
import { scorm12Type } from '../interaction-format.js';
|
|
2
|
+
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
|
|
3
|
+
import { formatHHMMSS } from './retry.js';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* SCORM 1.2 API interface.
|
|
@@ -17,84 +16,51 @@ export interface SCORM12API {
|
|
|
17
16
|
LMSGetDiagnostic(errorCode: string): string;
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
|
|
20
|
+
sessionTimeKey: 'cmi.core.session_time',
|
|
21
|
+
formatDuration: formatHHMMSS,
|
|
22
|
+
interactionFields: {
|
|
23
|
+
responseField: 'student_response',
|
|
24
|
+
timestampField: 'time',
|
|
25
|
+
timestamp: () => new Date().toTimeString().slice(0, 8),
|
|
26
|
+
typeValue: (t) => scorm12Type(t),
|
|
27
|
+
resultLabels: { correct: 'correct', incorrect: 'wrong' },
|
|
28
|
+
},
|
|
29
|
+
initialize: (api) => api.LMSInitialize(''),
|
|
30
|
+
terminate: (api) => api.LMSFinish(''),
|
|
31
|
+
getValue: (api, key) => api.LMSGetValue(key),
|
|
32
|
+
setValue: (api, key, value) => api.LMSSetValue(key, value),
|
|
33
|
+
commit: (api) => api.LMSCommit(''),
|
|
34
|
+
getLastError: (api) => api.LMSGetLastError(),
|
|
35
|
+
getErrorString: (api, code) => api.LMSGetErrorString(code),
|
|
36
|
+
};
|
|
37
|
+
|
|
20
38
|
/**
|
|
21
39
|
* SCORM 1.2 persistence adapter.
|
|
22
40
|
*
|
|
23
41
|
* Uses a sequential write queue for all LMS SetValue/Commit calls.
|
|
24
42
|
* On terminate, the queue is drained synchronously (single attempt)
|
|
25
43
|
* since async retries cannot complete during page unload.
|
|
44
|
+
*
|
|
45
|
+
* SCORM 1.2 collapses completion + success into a single `lesson_status`
|
|
46
|
+
* field, so the two setters track their values separately and write the
|
|
47
|
+
* combined result through `#flushLessonStatus`.
|
|
26
48
|
*/
|
|
27
|
-
export class SCORM12Adapter
|
|
28
|
-
|
|
29
|
-
#queue = new WriteQueue();
|
|
30
|
-
#state: SavedState | null = null;
|
|
31
|
-
#terminated = false;
|
|
32
|
-
|
|
33
|
-
// SCORM 1.2 combines completion and success into a single lesson_status field
|
|
49
|
+
export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
50
|
+
// SCORM 1.2 combines completion and success into a single lesson_status field.
|
|
34
51
|
#completionStatus: string = 'incomplete';
|
|
35
52
|
#successStatus: string | null = null;
|
|
36
|
-
#interactionCount = 0;
|
|
37
53
|
|
|
38
54
|
constructor(api: SCORM12API) {
|
|
39
|
-
|
|
40
|
-
// Wire up GetLastError/GetErrorString so retry warnings can name the
|
|
41
|
-
// real LMS failure (e.g. "201 Invalid argument error") instead of a
|
|
42
|
-
// generic "LMS call failed" — production triage needs the code.
|
|
43
|
-
this.#queue.errorReporter = {
|
|
44
|
-
code: () => this.#api.LMSGetLastError(),
|
|
45
|
-
message: (c) => this.#api.LMSGetErrorString(c),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Expose the underlying SCORM 1.2 API so xAPI actor synthesis can read learner fields. */
|
|
50
|
-
getAPI(): SCORM12API {
|
|
51
|
-
return this.#api;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async init(): Promise<void> {
|
|
55
|
-
await withRetry(() => this.#api.LMSInitialize(''));
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
const raw = this.#api.LMSGetValue('cmi.suspend_data');
|
|
59
|
-
if (raw && raw.trim()) {
|
|
60
|
-
this.#state = JSON.parse(raw);
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
this.#state = null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Continue cmi.interactions.n indexing where the previous session left
|
|
67
|
-
// off. Restarting at 0 would overwrite prior records (the LMS uses n
|
|
68
|
-
// as the array key, not an upsert field).
|
|
69
|
-
try {
|
|
70
|
-
const count = this.#api.LMSGetValue('cmi.interactions._count');
|
|
71
|
-
const n = parseInt(count, 10);
|
|
72
|
-
if (Number.isFinite(n) && n >= 0) this.#interactionCount = n;
|
|
73
|
-
} catch {
|
|
74
|
-
// Some LMSes throw on _count when no interactions exist — fall back to 0.
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
getState(): SavedState | null {
|
|
79
|
-
return this.#state;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
saveState(state: SavedState): void {
|
|
83
|
-
this.#state = state;
|
|
84
|
-
const json = JSON.stringify(state);
|
|
85
|
-
this.#queue.enqueue(() => this.#api.LMSSetValue('cmi.suspend_data', json));
|
|
55
|
+
super(api, SCORM12_DIALECT);
|
|
86
56
|
}
|
|
87
57
|
|
|
88
58
|
setScore(score: number): void {
|
|
89
|
-
this
|
|
90
|
-
this
|
|
91
|
-
);
|
|
92
|
-
this.#queue.enqueue(() =>
|
|
93
|
-
this.#api.LMSSetValue('cmi.core.score.min', '0')
|
|
94
|
-
);
|
|
95
|
-
this.#queue.enqueue(() =>
|
|
96
|
-
this.#api.LMSSetValue('cmi.core.score.max', '100')
|
|
59
|
+
this.queue.enqueue(() =>
|
|
60
|
+
this.api.LMSSetValue('cmi.core.score.raw', String(score))
|
|
97
61
|
);
|
|
62
|
+
this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.min', '0'));
|
|
63
|
+
this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.max', '100'));
|
|
98
64
|
}
|
|
99
65
|
|
|
100
66
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
@@ -110,17 +76,10 @@ export class SCORM12Adapter implements PersistenceAdapter {
|
|
|
110
76
|
}
|
|
111
77
|
|
|
112
78
|
#flushLessonStatus(): void {
|
|
113
|
-
// Success status takes priority — it's the more specific status
|
|
79
|
+
// Success status takes priority — it's the more specific status.
|
|
114
80
|
const value = this.#successStatus ?? this.#completionStatus;
|
|
115
|
-
this
|
|
116
|
-
this
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
setDuration(seconds: number): void {
|
|
121
|
-
const formatted = formatHHMMSS(seconds);
|
|
122
|
-
this.#queue.enqueue(() =>
|
|
123
|
-
this.#api.LMSSetValue('cmi.core.session_time', formatted)
|
|
81
|
+
this.queue.enqueue(() =>
|
|
82
|
+
this.api.LMSSetValue('cmi.core.lesson_status', value)
|
|
124
83
|
);
|
|
125
84
|
}
|
|
126
85
|
|
|
@@ -128,45 +87,6 @@ export class SCORM12Adapter implements PersistenceAdapter {
|
|
|
128
87
|
// SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
|
|
129
88
|
// We only map 'suspend' and the empty/normal case.
|
|
130
89
|
const value = mode === 'suspend' ? 'suspend' : '';
|
|
131
|
-
this
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
reportInteraction(
|
|
135
|
-
questionId: string,
|
|
136
|
-
interaction: Interaction,
|
|
137
|
-
correct: boolean | null
|
|
138
|
-
): void {
|
|
139
|
-
const n = this.#interactionCount++;
|
|
140
|
-
const fields = buildScormInteractionFields(
|
|
141
|
-
`cmi.interactions.${n}`,
|
|
142
|
-
questionId,
|
|
143
|
-
interaction,
|
|
144
|
-
correct,
|
|
145
|
-
{
|
|
146
|
-
responseField: 'student_response',
|
|
147
|
-
timestampField: 'time',
|
|
148
|
-
timestamp: new Date().toTimeString().slice(0, 8),
|
|
149
|
-
typeValue: scorm12Type(interaction.type),
|
|
150
|
-
resultLabels: { correct: 'correct', incorrect: 'wrong' },
|
|
151
|
-
}
|
|
152
|
-
);
|
|
153
|
-
for (const [key, value] of fields) {
|
|
154
|
-
this.#queue.enqueue(() => this.#api.LMSSetValue(key, value));
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
commit(): void {
|
|
159
|
-
this.#queue.enqueue(() => this.#api.LMSCommit(''));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
terminate(): void {
|
|
163
|
-
if (this.#terminated) return;
|
|
164
|
-
this.#terminated = true;
|
|
165
|
-
// During page unload, async retries can't run.
|
|
166
|
-
// Drain any pending queue operations synchronously (single attempt each),
|
|
167
|
-
// then commit and finish synchronously.
|
|
168
|
-
this.#queue.drainSync();
|
|
169
|
-
callSync(() => this.#api.LMSCommit(''));
|
|
170
|
-
callSync(() => this.#api.LMSFinish(''));
|
|
90
|
+
this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.exit', value));
|
|
171
91
|
}
|
|
172
92
|
}
|