tessera-learn 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +50 -21
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
- package/dist/inline-config-DVvOCKht.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +91 -49
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +287 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +96 -46
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +138 -93
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +24 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +255 -238
- package/src/runtime/App.svelte +14 -9
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -54
- package/src/runtime/adapters/web.ts +11 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/fingerprint.ts +28 -0
- package/src/runtime/interaction-format.ts +0 -1
- package/src/runtime/persistence.ts +4 -0
- package/src/runtime/types.ts +22 -1
- package/src/runtime/xapi/publisher.ts +16 -15
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit--fSWIOgK.js.map +0 -1
- package/dist/build-commands-Qyrlsp3n.js.map +0 -1
- package/dist/inline-config-DqAKsCNl.js.map +0 -1
- package/dist/plugin-B-aiL9-V.js.map +0 -1
|
@@ -36,3 +36,14 @@ export function hasCMI5LaunchParams(): boolean {
|
|
|
36
36
|
params.get('actor')
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Plain xAPI ("Tin Can") launch params on the URL. No fetch token required; `auth` is the Basic credential the LMS supplies in the launch link. */
|
|
41
|
+
export function hasXAPILaunchParams(): boolean {
|
|
42
|
+
const params = new URLSearchParams(window.location.search);
|
|
43
|
+
return !!(
|
|
44
|
+
params.get('endpoint') &&
|
|
45
|
+
params.get('auth') &&
|
|
46
|
+
params.get('actor') &&
|
|
47
|
+
params.get('activity_id')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import type { CourseConfig } from '../types.js';
|
|
2
|
+
import type { Manifest } from '../../plugin/manifest.js';
|
|
2
3
|
import type { PersistenceAdapter } from '../persistence.js';
|
|
3
4
|
import { WebAdapter } from './web.js';
|
|
4
5
|
import { SCORM12Adapter } from './scorm12.js';
|
|
5
6
|
import { SCORM2004Adapter } from './scorm2004.js';
|
|
6
7
|
import { CMI5Adapter } from './cmi5.js';
|
|
8
|
+
import { XAPIAdapter } from './xapi.js';
|
|
7
9
|
import {
|
|
8
10
|
findSCORM12API,
|
|
9
11
|
findSCORM2004API,
|
|
10
12
|
hasCMI5LaunchParams,
|
|
13
|
+
hasXAPILaunchParams,
|
|
11
14
|
} from './discovery.js';
|
|
15
|
+
import {
|
|
16
|
+
LMSAdapterError,
|
|
17
|
+
lmsWarnLabel,
|
|
18
|
+
missingApiError,
|
|
19
|
+
type LMSStandard,
|
|
20
|
+
} from './lms-error.js';
|
|
12
21
|
|
|
13
|
-
export
|
|
14
|
-
standard: 'scorm12' | 'scorm2004' | 'cmi5';
|
|
15
|
-
constructor(standard: 'scorm12' | 'scorm2004' | 'cmi5', message: string) {
|
|
16
|
-
super(message);
|
|
17
|
-
this.name = 'LMSAdapterError';
|
|
18
|
-
this.standard = standard;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
22
|
+
export { LMSAdapterError, missingApiError };
|
|
21
23
|
|
|
22
24
|
export interface CreateAdapterOptions {
|
|
23
25
|
/**
|
|
@@ -26,59 +28,24 @@ export interface CreateAdapterOptions {
|
|
|
26
28
|
* so dev builds stay forgiving and production builds fail loud.
|
|
27
29
|
*/
|
|
28
30
|
allowFallback?: boolean;
|
|
31
|
+
/** Course manifest — lets the WebAdapter fingerprint its page structure into the storage key. */
|
|
32
|
+
manifest?: Manifest;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
{
|
|
37
|
-
detect: () => PersistenceAdapter | null;
|
|
38
|
-
warnLabel: string;
|
|
39
|
-
name: string;
|
|
40
|
-
missingDetail: string;
|
|
41
|
-
}
|
|
42
|
-
> = {
|
|
43
|
-
scorm12: {
|
|
44
|
-
detect: () => {
|
|
45
|
-
const api = findSCORM12API();
|
|
46
|
-
return api ? new SCORM12Adapter(api) : null;
|
|
47
|
-
},
|
|
48
|
-
warnLabel: 'SCORM 1.2 API',
|
|
49
|
-
name: 'SCORM 1.2',
|
|
50
|
-
missingDetail:
|
|
51
|
-
'No SCORM 1.2 API object found in the window.parent or window.opener chain.',
|
|
52
|
-
},
|
|
53
|
-
scorm2004: {
|
|
54
|
-
detect: () => {
|
|
55
|
-
const api = findSCORM2004API();
|
|
56
|
-
return api ? new SCORM2004Adapter(api) : null;
|
|
57
|
-
},
|
|
58
|
-
warnLabel: 'SCORM 2004 API',
|
|
59
|
-
name: 'SCORM 2004',
|
|
60
|
-
missingDetail:
|
|
61
|
-
'No SCORM 2004 API object found in the window.parent or window.opener chain.',
|
|
35
|
+
/** Per-standard LMS wiring: `detect` returns an adapter when the LMS runtime is reachable, else null. Labels and the fail-loud error live in `./lms-error.js`. */
|
|
36
|
+
const LMS_ADAPTERS: Record<LMSStandard, () => PersistenceAdapter | null> = {
|
|
37
|
+
scorm12: () => {
|
|
38
|
+
const api = findSCORM12API();
|
|
39
|
+
return api ? new SCORM12Adapter(api) : null;
|
|
62
40
|
},
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
name: 'cmi5',
|
|
67
|
-
missingDetail:
|
|
68
|
-
'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.',
|
|
41
|
+
scorm2004: () => {
|
|
42
|
+
const api = findSCORM2004API();
|
|
43
|
+
return api ? new SCORM2004Adapter(api) : null;
|
|
69
44
|
},
|
|
45
|
+
cmi5: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
|
|
46
|
+
xapi: () => (hasXAPILaunchParams() ? new XAPIAdapter() : null),
|
|
70
47
|
};
|
|
71
48
|
|
|
72
|
-
function missingApiError(standard: LMSStandard): LMSAdapterError {
|
|
73
|
-
const { name, missingDetail } = LMS_ADAPTERS[standard];
|
|
74
|
-
return new LMSAdapterError(
|
|
75
|
-
standard,
|
|
76
|
-
`Tessera: this course is configured for ${name} but ${missingDetail} ` +
|
|
77
|
-
`The course must be launched from an LMS that provides the ${name} runtime. ` +
|
|
78
|
-
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
49
|
/**
|
|
83
50
|
* Select the appropriate persistence adapter based on course config.
|
|
84
51
|
*
|
|
@@ -99,15 +66,15 @@ export function createAdapter(
|
|
|
99
66
|
if (
|
|
100
67
|
standard === 'scorm12' ||
|
|
101
68
|
standard === 'scorm2004' ||
|
|
102
|
-
standard === 'cmi5'
|
|
69
|
+
standard === 'cmi5' ||
|
|
70
|
+
standard === 'xapi'
|
|
103
71
|
) {
|
|
104
|
-
const
|
|
105
|
-
const adapter = entry.detect();
|
|
72
|
+
const adapter = LMS_ADAPTERS[standard]();
|
|
106
73
|
if (adapter) return adapter;
|
|
107
74
|
if (!allowFallback) throw missingApiError(standard);
|
|
108
75
|
console.warn(
|
|
109
|
-
`Tessera (dev): ${
|
|
76
|
+
`Tessera (dev): ${lmsWarnLabel(standard)} not found — falling back to localStorage`,
|
|
110
77
|
);
|
|
111
78
|
}
|
|
112
|
-
return new WebAdapter(config);
|
|
79
|
+
return new WebAdapter(config, options.manifest);
|
|
113
80
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-standard LMS labels and the missing-API error, kept free of any adapter
|
|
3
|
+
* imports so both the runtime selector (`createAdapter`) and the build-time
|
|
4
|
+
* generated single-adapter modules can share one source of truth without
|
|
5
|
+
* pulling every adapter into a production bundle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi';
|
|
9
|
+
|
|
10
|
+
export class LMSAdapterError extends Error {
|
|
11
|
+
standard: LMSStandard;
|
|
12
|
+
constructor(standard: LMSStandard, message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'LMSAdapterError';
|
|
15
|
+
this.standard = standard;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STANDARD_INFO: Record<
|
|
20
|
+
LMSStandard,
|
|
21
|
+
{ name: string; warnLabel: string; missingDetail: string }
|
|
22
|
+
> = {
|
|
23
|
+
scorm12: {
|
|
24
|
+
name: 'SCORM 1.2',
|
|
25
|
+
warnLabel: 'SCORM 1.2 API',
|
|
26
|
+
missingDetail:
|
|
27
|
+
'No SCORM 1.2 API object found in the window.parent or window.opener chain.',
|
|
28
|
+
},
|
|
29
|
+
scorm2004: {
|
|
30
|
+
name: 'SCORM 2004',
|
|
31
|
+
warnLabel: 'SCORM 2004 API',
|
|
32
|
+
missingDetail:
|
|
33
|
+
'No SCORM 2004 API object found in the window.parent or window.opener chain.',
|
|
34
|
+
},
|
|
35
|
+
cmi5: {
|
|
36
|
+
name: 'cmi5',
|
|
37
|
+
warnLabel: 'cmi5 launch parameters',
|
|
38
|
+
missingDetail:
|
|
39
|
+
'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.',
|
|
40
|
+
},
|
|
41
|
+
xapi: {
|
|
42
|
+
name: 'xAPI 1.0.3',
|
|
43
|
+
warnLabel: 'xAPI launch parameters',
|
|
44
|
+
missingDetail:
|
|
45
|
+
'No xAPI launch parameters (endpoint / auth / actor / activity_id) on the URL.',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function lmsWarnLabel(standard: LMSStandard): string {
|
|
50
|
+
return STANDARD_INFO[standard].warnLabel;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function missingApiError(standard: LMSStandard): LMSAdapterError {
|
|
54
|
+
const { name, missingDetail } = STANDARD_INFO[standard];
|
|
55
|
+
return new LMSAdapterError(
|
|
56
|
+
standard,
|
|
57
|
+
`Tessera: this course is configured for ${name} but ${missingDetail} ` +
|
|
58
|
+
`The course must be launched from an LMS that provides the ${name} runtime. ` +
|
|
59
|
+
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -66,6 +66,16 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
66
66
|
return this.api;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// SCORM 2004 overrides this to block writes in browse/review mode (§4.2.1.5).
|
|
70
|
+
protected canWrite(): boolean {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected set(key: string, value: string): void {
|
|
75
|
+
if (!this.canWrite()) return;
|
|
76
|
+
this.queue.enqueue(() => this.dialect.setValue(this.api, key, value), key);
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
async init(): Promise<void> {
|
|
70
80
|
const initialized = await withRetry(
|
|
71
81
|
() => this.dialect.initialize(this.api),
|
|
@@ -129,6 +139,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
saveState(state: SavedState): void {
|
|
142
|
+
if (!this.canWrite()) return;
|
|
132
143
|
this.#state = state;
|
|
133
144
|
const json = JSON.stringify(state);
|
|
134
145
|
if (
|
|
@@ -144,19 +155,11 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
144
155
|
`larger-limit standard (scorm2004/cmi5).`,
|
|
145
156
|
);
|
|
146
157
|
}
|
|
147
|
-
this.
|
|
148
|
-
() => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
|
|
149
|
-
'cmi.suspend_data',
|
|
150
|
-
);
|
|
158
|
+
this.set('cmi.suspend_data', json);
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
setDuration(seconds: number): void {
|
|
154
|
-
|
|
155
|
-
this.queue.enqueue(
|
|
156
|
-
() =>
|
|
157
|
-
this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
|
|
158
|
-
this.dialect.sessionTimeKey,
|
|
159
|
-
);
|
|
162
|
+
this.set(this.dialect.sessionTimeKey, this.dialect.formatDuration(seconds));
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
reportInteraction(
|
|
@@ -164,6 +167,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
164
167
|
interaction: Interaction,
|
|
165
168
|
correct: boolean | null,
|
|
166
169
|
): void {
|
|
170
|
+
if (!this.canWrite()) return;
|
|
167
171
|
const n = this.interactionCount++;
|
|
168
172
|
const fields = buildScormInteractionFields(
|
|
169
173
|
`cmi.interactions.${n}`,
|
|
@@ -180,10 +184,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
180
184
|
},
|
|
181
185
|
);
|
|
182
186
|
for (const [key, value] of fields) {
|
|
183
|
-
this.
|
|
184
|
-
() => this.dialect.setValue(this.api, key, value),
|
|
185
|
-
key,
|
|
186
|
-
);
|
|
187
|
+
this.set(key, value);
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -66,25 +66,13 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
66
66
|
saveState(state: SavedState): void {
|
|
67
67
|
super.saveState(state);
|
|
68
68
|
// §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
|
|
69
|
-
this.
|
|
70
|
-
() => this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
|
|
71
|
-
'cmi.core.lesson_location',
|
|
72
|
-
);
|
|
69
|
+
this.set('cmi.core.lesson_location', String(state.b));
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
setScore(score: number): void {
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
this.queue.enqueue(
|
|
81
|
-
() => this.api.LMSSetValue('cmi.core.score.min', '0'),
|
|
82
|
-
'cmi.core.score.min',
|
|
83
|
-
);
|
|
84
|
-
this.queue.enqueue(
|
|
85
|
-
() => this.api.LMSSetValue('cmi.core.score.max', '100'),
|
|
86
|
-
'cmi.core.score.max',
|
|
87
|
-
);
|
|
73
|
+
this.set('cmi.core.score.raw', formatReal107(score));
|
|
74
|
+
this.set('cmi.core.score.min', '0');
|
|
75
|
+
this.set('cmi.core.score.max', '100');
|
|
88
76
|
}
|
|
89
77
|
|
|
90
78
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
@@ -101,18 +89,11 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
101
89
|
|
|
102
90
|
#flushLessonStatus(): void {
|
|
103
91
|
const value = this.#successStatus ?? this.#completionStatus;
|
|
104
|
-
this.
|
|
105
|
-
() => this.api.LMSSetValue('cmi.core.lesson_status', value),
|
|
106
|
-
'cmi.core.lesson_status',
|
|
107
|
-
);
|
|
92
|
+
this.set('cmi.core.lesson_status', value);
|
|
108
93
|
}
|
|
109
94
|
|
|
110
95
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
111
96
|
// SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
|
|
112
|
-
|
|
113
|
-
this.queue.enqueue(
|
|
114
|
-
() => this.api.LMSSetValue('cmi.core.exit', value),
|
|
115
|
-
'cmi.core.exit',
|
|
116
|
-
);
|
|
97
|
+
this.set('cmi.core.exit', mode === 'suspend' ? 'suspend' : '');
|
|
117
98
|
}
|
|
118
99
|
}
|
|
@@ -74,7 +74,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
74
74
|
return this.#masteryScore;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
protected canWrite(): boolean {
|
|
78
78
|
return this.#mode === 'normal';
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -97,80 +97,38 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
saveState(state: SavedState): void {
|
|
100
|
-
if (!this.#canWrite) return;
|
|
101
100
|
super.saveState(state);
|
|
102
101
|
// §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
|
|
103
|
-
this.
|
|
104
|
-
() => this.api.SetValue('cmi.location', String(state.b)),
|
|
105
|
-
'cmi.location',
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
setDuration(seconds: number): void {
|
|
110
|
-
if (!this.#canWrite) return;
|
|
111
|
-
super.setDuration(seconds);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
reportInteraction(
|
|
115
|
-
questionId: string,
|
|
116
|
-
interaction: import('../interaction.js').Interaction,
|
|
117
|
-
correct: boolean | null,
|
|
118
|
-
): void {
|
|
119
|
-
if (!this.#canWrite) return;
|
|
120
|
-
super.reportInteraction(questionId, interaction, correct);
|
|
102
|
+
this.set('cmi.location', String(state.b));
|
|
121
103
|
}
|
|
122
104
|
|
|
123
105
|
setScore(score: number): void {
|
|
124
|
-
|
|
125
|
-
|
|
106
|
+
this.set('cmi.score.raw', formatReal107(score));
|
|
107
|
+
this.set('cmi.score.min', '0');
|
|
108
|
+
this.set('cmi.score.max', '100');
|
|
126
109
|
// §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
|
|
127
|
-
|
|
128
|
-
this.queue.enqueue(
|
|
129
|
-
() => this.api.SetValue('cmi.score.raw', raw),
|
|
130
|
-
'cmi.score.raw',
|
|
131
|
-
);
|
|
132
|
-
this.queue.enqueue(
|
|
133
|
-
() => this.api.SetValue('cmi.score.min', '0'),
|
|
134
|
-
'cmi.score.min',
|
|
135
|
-
);
|
|
136
|
-
this.queue.enqueue(
|
|
137
|
-
() => this.api.SetValue('cmi.score.max', '100'),
|
|
138
|
-
'cmi.score.max',
|
|
139
|
-
);
|
|
140
|
-
this.queue.enqueue(
|
|
141
|
-
() => this.api.SetValue('cmi.score.scaled', scaled),
|
|
110
|
+
this.set(
|
|
142
111
|
'cmi.score.scaled',
|
|
112
|
+
formatReal107(Math.max(0, Math.min(1, score / 100))),
|
|
143
113
|
);
|
|
144
114
|
}
|
|
145
115
|
|
|
146
116
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
147
|
-
|
|
148
|
-
const value = status === 'complete' ? 'completed' : 'incomplete';
|
|
149
|
-
this.queue.enqueue(
|
|
150
|
-
() => this.api.SetValue('cmi.completion_status', value),
|
|
117
|
+
this.set(
|
|
151
118
|
'cmi.completion_status',
|
|
119
|
+
status === 'complete' ? 'completed' : 'incomplete',
|
|
152
120
|
);
|
|
153
121
|
// §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
|
|
154
|
-
if (status === 'complete')
|
|
155
|
-
this.queue.enqueue(
|
|
156
|
-
() => this.api.SetValue('cmi.progress_measure', '1'),
|
|
157
|
-
'cmi.progress_measure',
|
|
158
|
-
);
|
|
159
|
-
}
|
|
122
|
+
if (status === 'complete') this.set('cmi.progress_measure', '1');
|
|
160
123
|
}
|
|
161
124
|
|
|
162
125
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
163
|
-
if (!this.#canWrite) return;
|
|
164
126
|
// Setting "unknown" explicitly prevents SCORM Cloud from rolling up
|
|
165
127
|
// a null status to "passed".
|
|
166
|
-
this.
|
|
167
|
-
() => this.api.SetValue('cmi.success_status', status),
|
|
168
|
-
'cmi.success_status',
|
|
169
|
-
);
|
|
128
|
+
this.set('cmi.success_status', status);
|
|
170
129
|
}
|
|
171
130
|
|
|
172
131
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
173
|
-
|
|
174
|
-
this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
|
|
132
|
+
this.set('cmi.exit', mode);
|
|
175
133
|
}
|
|
176
134
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
2
|
import type { CourseConfig } from '../types.js';
|
|
3
|
+
import { courseIdentity } from '../types.js';
|
|
3
4
|
import type { Interaction } from '../interaction.js';
|
|
4
|
-
import {
|
|
5
|
+
import type { Manifest } from '../../plugin/manifest.js';
|
|
6
|
+
import { structureFingerprint } from '../fingerprint.js';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Web persistence adapter — stores course state in localStorage.
|
|
@@ -11,9 +13,14 @@ export class WebAdapter implements PersistenceAdapter {
|
|
|
11
13
|
#storageKey: string;
|
|
12
14
|
#state: SavedState | null = null;
|
|
13
15
|
|
|
14
|
-
constructor(config: CourseConfig) {
|
|
15
|
-
const
|
|
16
|
-
|
|
16
|
+
constructor(config: CourseConfig, manifest?: Manifest) {
|
|
17
|
+
const base = courseIdentity(config) || 'tessera-course';
|
|
18
|
+
// Fingerprint in the key invalidates web resume on a structure change (a
|
|
19
|
+
// changed key misses, so getState() returns null). LMS adapters can't key
|
|
20
|
+
// their storage, so they rely on SavedState.f + shouldRestore instead. Keep
|
|
21
|
+
// both — neither mechanism covers the other's adapters.
|
|
22
|
+
const fp = manifest ? structureFingerprint(manifest) : '';
|
|
23
|
+
this.#storageKey = `tessera-${base}${fp ? `-${fp}` : ''}`;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
async init(): Promise<void> {
|