@lessonkit/core 1.4.0 → 1.6.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/README.md +28 -2
- package/dist/{chunk-PEWFPVQ6.js → chunk-NGCHHJSM.js} +357 -50
- package/dist/index.cjs +822 -131
- package/dist/index.d.cts +142 -15
- package/dist/index.d.ts +142 -15
- package/dist/index.js +534 -138
- package/dist/{testing-BhVGckZ5.d.cts → testing-CzgxF1Ru.d.cts} +193 -8
- package/dist/{testing-BhVGckZ5.d.ts → testing-CzgxF1Ru.d.ts} +193 -8
- package/dist/testing.cjs +1 -3
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +3 -3
- package/telemetry-catalog.v3.json +177 -0
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ID_MAX_LENGTH,
|
|
3
|
+
ID_PATTERN,
|
|
2
4
|
SESSION_STORAGE_KEY,
|
|
5
|
+
assertValidId,
|
|
3
6
|
buildCourseStartedTelemetryEvent,
|
|
4
7
|
buildTelemetryEvent,
|
|
5
8
|
completeCourseWithTelemetry,
|
|
@@ -13,82 +16,33 @@ import {
|
|
|
13
16
|
hasCourseStarted,
|
|
14
17
|
hasCourseStartedEmittedToTracking,
|
|
15
18
|
hasCourseStartedPipelineDelivered,
|
|
19
|
+
hasCourseStartedXapiSent,
|
|
16
20
|
isDevEnvironment,
|
|
17
21
|
markCourseStarted,
|
|
18
22
|
markCourseStartedEmittedToTracking,
|
|
19
23
|
markCourseStartedPipelineDelivered,
|
|
24
|
+
markCourseStartedXapiSent,
|
|
20
25
|
migrateCourseStartedMark,
|
|
21
26
|
nowIso,
|
|
27
|
+
parseBlockId,
|
|
28
|
+
parseCheckId,
|
|
29
|
+
parseCourseId,
|
|
30
|
+
parseLessonId,
|
|
22
31
|
resetSharedVolatileSessionIdForTests,
|
|
23
32
|
resetStoragePortForTests,
|
|
24
33
|
resetTelemetryBuilderWarningsForTests,
|
|
25
34
|
resolveSessionId,
|
|
26
35
|
tryBuildTelemetryEvent,
|
|
27
36
|
tryEmitCourseStarted,
|
|
37
|
+
validateId,
|
|
28
38
|
warnDev
|
|
29
|
-
} from "./chunk-
|
|
30
|
-
|
|
31
|
-
// src/identityTypes.ts
|
|
32
|
-
var ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
33
|
-
var ID_MAX_LENGTH = 64;
|
|
39
|
+
} from "./chunk-NGCHHJSM.js";
|
|
34
40
|
|
|
35
41
|
// src/assertNever.ts
|
|
36
42
|
function assertNever(value, message = "Unexpected value") {
|
|
37
43
|
throw new Error(`${message}: ${String(value)}`);
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
// src/validateId.ts
|
|
41
|
-
function validateId(input, path = "id") {
|
|
42
|
-
if (typeof input !== "string") {
|
|
43
|
-
return { ok: false, issues: [{ path, message: "id must be a string" }] };
|
|
44
|
-
}
|
|
45
|
-
const id = input.trim();
|
|
46
|
-
if (!id.length) {
|
|
47
|
-
return { ok: false, issues: [{ path, message: "id must not be empty" }] };
|
|
48
|
-
}
|
|
49
|
-
if (id.length > ID_MAX_LENGTH) {
|
|
50
|
-
return {
|
|
51
|
-
ok: false,
|
|
52
|
-
issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
if (!ID_PATTERN.test(id)) {
|
|
56
|
-
return {
|
|
57
|
-
ok: false,
|
|
58
|
-
issues: [
|
|
59
|
-
{
|
|
60
|
-
path,
|
|
61
|
-
message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
|
|
62
|
-
}
|
|
63
|
-
]
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
return { ok: true, id };
|
|
67
|
-
}
|
|
68
|
-
function parseCourseId(input) {
|
|
69
|
-
const result = validateId(input, "courseId");
|
|
70
|
-
return result.ok ? result.id : null;
|
|
71
|
-
}
|
|
72
|
-
function parseLessonId(input) {
|
|
73
|
-
const result = validateId(input, "lessonId");
|
|
74
|
-
return result.ok ? result.id : null;
|
|
75
|
-
}
|
|
76
|
-
function parseCheckId(input) {
|
|
77
|
-
const result = validateId(input, "checkId");
|
|
78
|
-
return result.ok ? result.id : null;
|
|
79
|
-
}
|
|
80
|
-
function parseBlockId(input) {
|
|
81
|
-
const result = validateId(input, "blockId");
|
|
82
|
-
return result.ok ? result.id : null;
|
|
83
|
-
}
|
|
84
|
-
function assertValidId(input, path = "id") {
|
|
85
|
-
const result = validateId(input, path);
|
|
86
|
-
if (!result.ok) {
|
|
87
|
-
throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
88
|
-
}
|
|
89
|
-
return result.id;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
46
|
// src/slugify.ts
|
|
93
47
|
function shortHash(input) {
|
|
94
48
|
let h = 0;
|
|
@@ -101,12 +55,26 @@ function uniqueFallbackId(input, usedIds) {
|
|
|
101
55
|
const hash = shortHash(input);
|
|
102
56
|
for (let n = 0; n < 100; n++) {
|
|
103
57
|
const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
58
|
+
const validated = validateId(candidate);
|
|
59
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
60
|
+
}
|
|
61
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
62
|
+
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
63
|
+
const candidate = `id-${hash}-${randomSuffix}`.slice(0, 64);
|
|
64
|
+
const validated = validateId(candidate);
|
|
65
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
106
66
|
}
|
|
107
67
|
const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
|
|
108
|
-
const
|
|
109
|
-
|
|
68
|
+
const timedValidated = validateId(timed);
|
|
69
|
+
if (timedValidated.ok && !usedIds.has(timedValidated.id)) return timedValidated.id;
|
|
70
|
+
const cryptoApi = globalThis.crypto;
|
|
71
|
+
for (let attempt = 0; attempt < 1e3; attempt++) {
|
|
72
|
+
const suffix = typeof cryptoApi?.randomUUID === "function" ? cryptoApi.randomUUID().replace(/-/g, "").slice(0, 12) : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
73
|
+
const candidate = `id-${hash}-${suffix}`.slice(0, 64);
|
|
74
|
+
const validated = validateId(candidate);
|
|
75
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`[lessonkit] unable to derive unique id for input: ${input.slice(0, 32)}`);
|
|
110
78
|
}
|
|
111
79
|
function slugifyId(input) {
|
|
112
80
|
const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
|
|
@@ -148,6 +116,13 @@ function buildLessonkitUrn(parts) {
|
|
|
148
116
|
}
|
|
149
117
|
urn += `:block:${blockId}`;
|
|
150
118
|
}
|
|
119
|
+
if (parts.nodeId !== void 0) {
|
|
120
|
+
const nodeId = assertValidId(parts.nodeId, "nodeId");
|
|
121
|
+
if (parts.blockId === void 0) {
|
|
122
|
+
throw new Error("buildLessonkitUrn: nodeId requires blockId");
|
|
123
|
+
}
|
|
124
|
+
urn += `:node:${nodeId}`;
|
|
125
|
+
}
|
|
151
126
|
return urn;
|
|
152
127
|
}
|
|
153
128
|
|
|
@@ -192,23 +167,31 @@ function isPlainSerializableChildState(value) {
|
|
|
192
167
|
(entry) => isValidChildResumeValue(entry)
|
|
193
168
|
);
|
|
194
169
|
}
|
|
195
|
-
function parseCompoundResumeState(raw) {
|
|
170
|
+
function parseCompoundResumeState(raw, opts) {
|
|
196
171
|
if (!raw || typeof raw !== "object") return null;
|
|
197
172
|
const obj = raw;
|
|
198
173
|
if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
|
|
199
174
|
if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
|
|
200
175
|
const childStates = {};
|
|
176
|
+
const droppedChildKeys = [];
|
|
201
177
|
if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
|
|
202
178
|
for (const [key, value] of Object.entries(obj.childStates)) {
|
|
203
179
|
if (isPlainSerializableChildState(value)) {
|
|
204
180
|
childStates[key] = value;
|
|
181
|
+
} else {
|
|
182
|
+
droppedChildKeys.push(key);
|
|
205
183
|
}
|
|
206
184
|
}
|
|
207
185
|
}
|
|
186
|
+
if (droppedChildKeys.length > 0) {
|
|
187
|
+
opts?.onDroppedChildKeys?.(droppedChildKeys);
|
|
188
|
+
}
|
|
208
189
|
const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
|
|
190
|
+
const rawPageIndex = Math.max(0, Math.floor(obj.activePageIndex));
|
|
191
|
+
const activePageIndex = typeof opts?.pageCount === "number" && opts.pageCount > 0 ? clampCompoundPageIndex(rawPageIndex, opts.pageCount) : rawPageIndex;
|
|
209
192
|
return {
|
|
210
193
|
schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
|
|
211
|
-
activePageIndex
|
|
194
|
+
activePageIndex,
|
|
212
195
|
...activeChapterIndex !== void 0 ? { activeChapterIndex: Math.max(0, Math.floor(activeChapterIndex)) } : {},
|
|
213
196
|
childStates
|
|
214
197
|
};
|
|
@@ -219,17 +202,21 @@ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
|
|
|
219
202
|
function compoundStateStorageKey(courseId, compoundId) {
|
|
220
203
|
return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
|
|
221
204
|
}
|
|
222
|
-
function loadCompoundState(storage, courseId, compoundId) {
|
|
205
|
+
function loadCompoundState(storage, courseId, compoundId, opts) {
|
|
223
206
|
const key = compoundStateStorageKey(courseId, compoundId);
|
|
224
207
|
const raw = storage.getItem(key);
|
|
225
208
|
if (!raw) return null;
|
|
226
209
|
try {
|
|
227
|
-
const parsed = parseCompoundResumeState(JSON.parse(raw));
|
|
228
|
-
if (parsed === null
|
|
229
|
-
|
|
210
|
+
const parsed = parseCompoundResumeState(JSON.parse(raw), opts);
|
|
211
|
+
if (parsed === null) {
|
|
212
|
+
opts?.onCorrupt?.();
|
|
213
|
+
if (isDevEnvironment()) {
|
|
214
|
+
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
215
|
+
}
|
|
230
216
|
}
|
|
231
217
|
return parsed;
|
|
232
218
|
} catch {
|
|
219
|
+
opts?.onCorrupt?.();
|
|
233
220
|
if (isDevEnvironment()) {
|
|
234
221
|
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
235
222
|
}
|
|
@@ -286,8 +273,110 @@ var PAGE_ALLOWED_CHILD_TYPES = [
|
|
|
286
273
|
"FindHotspot",
|
|
287
274
|
"FindMultipleHotspots",
|
|
288
275
|
"ImageSlider",
|
|
276
|
+
"Embed",
|
|
277
|
+
"Chart",
|
|
278
|
+
"Table",
|
|
279
|
+
"ImageJuxtaposition",
|
|
280
|
+
"Timeline",
|
|
281
|
+
"ImageSequence",
|
|
282
|
+
"Collage",
|
|
283
|
+
"AudioRecorder",
|
|
284
|
+
"CombinationLock",
|
|
285
|
+
"QrContent",
|
|
286
|
+
"Crossword",
|
|
287
|
+
"AdventCalendar",
|
|
289
288
|
"ProgressTracker"
|
|
290
289
|
];
|
|
290
|
+
var BRANCH_NODE_ALLOWED_CHILD_TYPES = [
|
|
291
|
+
"Text",
|
|
292
|
+
"Heading",
|
|
293
|
+
"Image",
|
|
294
|
+
"Video",
|
|
295
|
+
"Scenario",
|
|
296
|
+
"Reflection",
|
|
297
|
+
"Quiz",
|
|
298
|
+
"KnowledgeCheck",
|
|
299
|
+
"TrueFalse",
|
|
300
|
+
"FillInTheBlanks",
|
|
301
|
+
"DragAndDrop",
|
|
302
|
+
"DragTheWords",
|
|
303
|
+
"MarkTheWords",
|
|
304
|
+
"Summary",
|
|
305
|
+
"ImagePairing",
|
|
306
|
+
"ImageSequencing",
|
|
307
|
+
"MemoryGame",
|
|
308
|
+
"InformationWall",
|
|
309
|
+
"ParallaxSlideshow",
|
|
310
|
+
"Questionnaire",
|
|
311
|
+
"Essay",
|
|
312
|
+
"ArithmeticQuiz",
|
|
313
|
+
"Accordion",
|
|
314
|
+
"DialogCards",
|
|
315
|
+
"Flashcards",
|
|
316
|
+
"ImageHotspots",
|
|
317
|
+
"FindHotspot",
|
|
318
|
+
"FindMultipleHotspots",
|
|
319
|
+
"ImageSlider",
|
|
320
|
+
"Embed",
|
|
321
|
+
"Chart",
|
|
322
|
+
"Table",
|
|
323
|
+
"ImageJuxtaposition",
|
|
324
|
+
"Timeline",
|
|
325
|
+
"ImageSequence",
|
|
326
|
+
"Collage",
|
|
327
|
+
"AudioRecorder",
|
|
328
|
+
"CombinationLock",
|
|
329
|
+
"QrContent",
|
|
330
|
+
"Crossword",
|
|
331
|
+
"AdventCalendar",
|
|
332
|
+
"BranchChoice"
|
|
333
|
+
];
|
|
334
|
+
var BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES = ["BranchNode"];
|
|
335
|
+
var GAME_MAP_ALLOWED_CHILD_TYPES = ["MapStage"];
|
|
336
|
+
var MAP_STAGE_ALLOWED_CHILD_TYPES = [
|
|
337
|
+
"Text",
|
|
338
|
+
"Heading",
|
|
339
|
+
"Image",
|
|
340
|
+
"Video",
|
|
341
|
+
"Scenario",
|
|
342
|
+
"Reflection",
|
|
343
|
+
"Quiz",
|
|
344
|
+
"KnowledgeCheck",
|
|
345
|
+
"TrueFalse",
|
|
346
|
+
"FillInTheBlanks",
|
|
347
|
+
"DragAndDrop",
|
|
348
|
+
"DragTheWords",
|
|
349
|
+
"MarkTheWords",
|
|
350
|
+
"Summary",
|
|
351
|
+
"ImagePairing",
|
|
352
|
+
"ImageSequencing",
|
|
353
|
+
"MemoryGame",
|
|
354
|
+
"InformationWall",
|
|
355
|
+
"ParallaxSlideshow",
|
|
356
|
+
"Questionnaire",
|
|
357
|
+
"Essay",
|
|
358
|
+
"ArithmeticQuiz",
|
|
359
|
+
"Accordion",
|
|
360
|
+
"DialogCards",
|
|
361
|
+
"Flashcards",
|
|
362
|
+
"ImageHotspots",
|
|
363
|
+
"FindHotspot",
|
|
364
|
+
"FindMultipleHotspots",
|
|
365
|
+
"ImageSlider",
|
|
366
|
+
"Embed",
|
|
367
|
+
"Chart",
|
|
368
|
+
"Table",
|
|
369
|
+
"ImageJuxtaposition",
|
|
370
|
+
"Timeline",
|
|
371
|
+
"ImageSequence",
|
|
372
|
+
"Collage",
|
|
373
|
+
"AudioRecorder",
|
|
374
|
+
"CombinationLock",
|
|
375
|
+
"QrContent",
|
|
376
|
+
"Crossword",
|
|
377
|
+
"AdventCalendar",
|
|
378
|
+
"MapExit"
|
|
379
|
+
];
|
|
291
380
|
var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
|
|
292
381
|
var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
293
382
|
"Text",
|
|
@@ -318,7 +407,19 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
|
318
407
|
"ImageHotspots",
|
|
319
408
|
"FindHotspot",
|
|
320
409
|
"FindMultipleHotspots",
|
|
321
|
-
"ImageSlider"
|
|
410
|
+
"ImageSlider",
|
|
411
|
+
"Embed",
|
|
412
|
+
"Chart",
|
|
413
|
+
"Table",
|
|
414
|
+
"ImageJuxtaposition",
|
|
415
|
+
"Timeline",
|
|
416
|
+
"ImageSequence",
|
|
417
|
+
"Collage",
|
|
418
|
+
"AudioRecorder",
|
|
419
|
+
"CombinationLock",
|
|
420
|
+
"QrContent",
|
|
421
|
+
"Crossword",
|
|
422
|
+
"AdventCalendar"
|
|
322
423
|
];
|
|
323
424
|
var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
|
|
324
425
|
var TIMED_CUE_ALLOWED_CHILD_TYPES = [
|
|
@@ -360,7 +461,11 @@ var ALLOWLISTS = {
|
|
|
360
461
|
SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
|
|
361
462
|
TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
|
|
362
463
|
InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
|
|
363
|
-
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
|
|
464
|
+
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
465
|
+
BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
466
|
+
BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES,
|
|
467
|
+
GameMap: GAME_MAP_ALLOWED_CHILD_TYPES,
|
|
468
|
+
MapStage: MAP_STAGE_ALLOWED_CHILD_TYPES
|
|
364
469
|
};
|
|
365
470
|
var COMPOUND_MAX_NESTING_DEPTH = {
|
|
366
471
|
Page: 1,
|
|
@@ -369,7 +474,11 @@ var COMPOUND_MAX_NESTING_DEPTH = {
|
|
|
369
474
|
SlideDeck: 2,
|
|
370
475
|
TimedCue: 1,
|
|
371
476
|
InteractiveVideo: 2,
|
|
372
|
-
AssessmentSequence: 1
|
|
477
|
+
AssessmentSequence: 1,
|
|
478
|
+
BranchingScenario: 2,
|
|
479
|
+
BranchNode: 1,
|
|
480
|
+
GameMap: 2,
|
|
481
|
+
MapStage: 1
|
|
373
482
|
};
|
|
374
483
|
function getAllowedChildTypes(parent) {
|
|
375
484
|
return ALLOWLISTS[parent];
|
|
@@ -380,6 +489,82 @@ function isChildTypeAllowed(parent, childType) {
|
|
|
380
489
|
var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
|
|
381
490
|
var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
|
|
382
491
|
|
|
492
|
+
// src/branchGraph.ts
|
|
493
|
+
function validateBranchGraph(startNodeId, nodes) {
|
|
494
|
+
const issues = [];
|
|
495
|
+
if (nodes.length === 0) {
|
|
496
|
+
issues.push({ code: "empty_graph", message: "Branch graph has no nodes" });
|
|
497
|
+
return { ok: false, issues, reachableNodeIds: [] };
|
|
498
|
+
}
|
|
499
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
500
|
+
for (const node of nodes) {
|
|
501
|
+
if (nodeIds.has(node.nodeId)) {
|
|
502
|
+
issues.push({
|
|
503
|
+
code: "duplicate_node_id",
|
|
504
|
+
message: `Duplicate nodeId "${node.nodeId}"`,
|
|
505
|
+
nodeId: node.nodeId
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
nodeIds.add(node.nodeId);
|
|
509
|
+
}
|
|
510
|
+
if (!nodeIds.has(startNodeId)) {
|
|
511
|
+
issues.push({
|
|
512
|
+
code: "start_not_found",
|
|
513
|
+
message: `startNodeId "${startNodeId}" does not match any BranchNode`,
|
|
514
|
+
nodeId: startNodeId
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (nodes.length > 1 && nodeIds.has(startNodeId)) {
|
|
518
|
+
const startNode = nodes.find((n) => n.nodeId === startNodeId);
|
|
519
|
+
if (startNode && startNode.choices.length === 0) {
|
|
520
|
+
issues.push({
|
|
521
|
+
code: "start_no_choices",
|
|
522
|
+
message: `startNodeId "${startNodeId}" has no BranchChoice children in a multi-node scenario`,
|
|
523
|
+
nodeId: startNodeId
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const node of nodes) {
|
|
528
|
+
for (const choice of node.choices) {
|
|
529
|
+
if (!nodeIds.has(choice.targetNodeId)) {
|
|
530
|
+
issues.push({
|
|
531
|
+
code: "unknown_target",
|
|
532
|
+
message: `Choice from "${node.nodeId}" references unknown target "${choice.targetNodeId}"`,
|
|
533
|
+
nodeId: node.nodeId
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
539
|
+
if (nodeIds.has(startNodeId)) {
|
|
540
|
+
const queue = [startNodeId];
|
|
541
|
+
while (queue.length > 0) {
|
|
542
|
+
const current = queue.shift();
|
|
543
|
+
if (reachable.has(current)) continue;
|
|
544
|
+
reachable.add(current);
|
|
545
|
+
const node = nodes.find((n) => n.nodeId === current);
|
|
546
|
+
if (!node) continue;
|
|
547
|
+
for (const choice of node.choices) {
|
|
548
|
+
if (!reachable.has(choice.targetNodeId)) queue.push(choice.targetNodeId);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
for (const nodeId of nodeIds) {
|
|
553
|
+
if (!reachable.has(nodeId)) {
|
|
554
|
+
issues.push({
|
|
555
|
+
code: "unreachable_node",
|
|
556
|
+
message: `Node "${nodeId}" is not reachable from startNodeId "${startNodeId}"`,
|
|
557
|
+
nodeId
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
ok: issues.length === 0,
|
|
563
|
+
issues,
|
|
564
|
+
reachableNodeIds: [...reachable]
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
383
568
|
// src/telemetryCatalog.ts
|
|
384
569
|
var telemetryCatalogVersion = 1;
|
|
385
570
|
var TELEMETRY_EVENT_CATALOG = [
|
|
@@ -582,6 +767,94 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
|
|
|
582
767
|
dataFields: ["blockId", "fieldCount"],
|
|
583
768
|
xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
|
|
584
769
|
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
name: "branch_node_viewed",
|
|
773
|
+
description: "Learner viewed a node in a BranchingScenario",
|
|
774
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
775
|
+
dataFields: ["blockId", "nodeId", "nodeIndex", "nodeTitle"],
|
|
776
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
777
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{nodeId}"
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
name: "branch_selected",
|
|
781
|
+
description: "Learner selected a branch choice in a BranchingScenario",
|
|
782
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
783
|
+
dataFields: ["blockId", "fromNodeId", "toNodeId", "label", "scoreWeight"],
|
|
784
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
|
|
785
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{toNodeId}"
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: "image_juxtaposition_changed",
|
|
789
|
+
description: "Learner adjusted the before/after divider",
|
|
790
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
791
|
+
dataFields: ["blockId", "position"],
|
|
792
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
|
|
793
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
name: "timeline_event_viewed",
|
|
797
|
+
description: "Learner focused a timeline event",
|
|
798
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
799
|
+
dataFields: ["blockId", "eventId"],
|
|
800
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
801
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
name: "image_sequence_changed",
|
|
805
|
+
description: "Learner changed the image sequence frame",
|
|
806
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
807
|
+
dataFields: ["blockId", "frameIndex"],
|
|
808
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
|
|
809
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "audio_recording_started",
|
|
813
|
+
description: "Learner started an audio recording",
|
|
814
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
815
|
+
dataFields: ["blockId"],
|
|
816
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
|
|
817
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
name: "audio_recording_completed",
|
|
821
|
+
description: "Learner completed an audio recording",
|
|
822
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
823
|
+
dataFields: ["blockId"],
|
|
824
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
|
|
825
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
name: "qr_content_revealed",
|
|
829
|
+
description: "Learner revealed QR hidden content",
|
|
830
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
831
|
+
dataFields: ["blockId"],
|
|
832
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
833
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
name: "advent_door_opened",
|
|
837
|
+
description: "Learner opened an advent calendar door",
|
|
838
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
839
|
+
dataFields: ["blockId", "doorId", "day"],
|
|
840
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
|
|
841
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
name: "map_stage_viewed",
|
|
845
|
+
description: "Learner viewed a stage in a GameMap",
|
|
846
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
847
|
+
dataFields: ["blockId", "stageId", "stageIndex", "stageLabel"],
|
|
848
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
849
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:stage:{stageId}"
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
name: "map_exit_selected",
|
|
853
|
+
description: "Learner selected a map exit in a GameMap",
|
|
854
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
855
|
+
dataFields: ["blockId", "fromStageId", "toStageId", "label", "scoreWeight"],
|
|
856
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
|
|
857
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:stage:{toStageId}"
|
|
585
858
|
}
|
|
586
859
|
];
|
|
587
860
|
function buildTelemetryCatalogV3() {
|
|
@@ -613,22 +886,12 @@ function invokeTrackingSink(sink, event) {
|
|
|
613
886
|
void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
|
|
614
887
|
}
|
|
615
888
|
}
|
|
616
|
-
function invokePipelineSink(sinkId, emit) {
|
|
617
|
-
let result;
|
|
618
|
-
try {
|
|
619
|
-
result = emit();
|
|
620
|
-
} catch (err) {
|
|
621
|
-
warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
if (result != null && typeof result.catch === "function") {
|
|
625
|
-
void result.catch(
|
|
626
|
-
(err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
889
|
|
|
631
890
|
// src/trackingClient.ts
|
|
891
|
+
function eventDedupKey(event) {
|
|
892
|
+
const id = event.id?.trim();
|
|
893
|
+
return id || void 0;
|
|
894
|
+
}
|
|
632
895
|
function createTrackingClient(opts) {
|
|
633
896
|
const sink = opts?.sink;
|
|
634
897
|
const batchSink = opts?.batchSink;
|
|
@@ -646,13 +909,14 @@ function createTrackingClient(opts) {
|
|
|
646
909
|
let disposed2 = false;
|
|
647
910
|
return {
|
|
648
911
|
track: (event) => {
|
|
649
|
-
if (disposed2) return;
|
|
912
|
+
if (disposed2) return false;
|
|
650
913
|
if (sink) {
|
|
651
914
|
try {
|
|
652
915
|
invokeTrackingSink(sink, event);
|
|
653
916
|
} catch {
|
|
654
917
|
}
|
|
655
918
|
}
|
|
919
|
+
return true;
|
|
656
920
|
},
|
|
657
921
|
deliver: async (event) => {
|
|
658
922
|
if (disposed2) return false;
|
|
@@ -665,19 +929,32 @@ function createTrackingClient(opts) {
|
|
|
665
929
|
};
|
|
666
930
|
}
|
|
667
931
|
if (!sink && !batchSink) {
|
|
668
|
-
return { track: () =>
|
|
669
|
-
} };
|
|
932
|
+
return { track: () => true };
|
|
670
933
|
}
|
|
671
934
|
const buffer = [];
|
|
935
|
+
const pendingDeliverIds = /* @__PURE__ */ new Set();
|
|
672
936
|
let flushInFlight = null;
|
|
673
|
-
let inflightExitBatch = null;
|
|
674
937
|
let disposed = false;
|
|
675
938
|
let disposing = false;
|
|
676
939
|
let intervalId;
|
|
940
|
+
const clearPendingDeliverIds = (events) => {
|
|
941
|
+
for (const event of events) {
|
|
942
|
+
const key = eventDedupKey(event);
|
|
943
|
+
if (key) pendingDeliverIds.delete(key);
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
const isEventBuffered = (event) => {
|
|
947
|
+
const key = eventDedupKey(event);
|
|
948
|
+
if (!key) return false;
|
|
949
|
+
return buffer.some((buffered) => eventDedupKey(buffered) === key);
|
|
950
|
+
};
|
|
677
951
|
const runFlush = () => {
|
|
678
952
|
if (!buffer.length) return Promise.resolve(true);
|
|
679
953
|
const events = buffer.splice(0, buffer.length);
|
|
680
|
-
|
|
954
|
+
for (const event of events) {
|
|
955
|
+
const key = eventDedupKey(event);
|
|
956
|
+
if (key) pendingDeliverIds.add(key);
|
|
957
|
+
}
|
|
681
958
|
let succeeded = false;
|
|
682
959
|
return Promise.resolve().then(async () => {
|
|
683
960
|
if (batchSink) {
|
|
@@ -687,7 +964,7 @@ function createTrackingClient(opts) {
|
|
|
687
964
|
try {
|
|
688
965
|
await sink?.(events[i]);
|
|
689
966
|
} catch {
|
|
690
|
-
buffer.unshift(...events
|
|
967
|
+
buffer.unshift(...events);
|
|
691
968
|
return;
|
|
692
969
|
}
|
|
693
970
|
}
|
|
@@ -698,12 +975,13 @@ function createTrackingClient(opts) {
|
|
|
698
975
|
buffer.unshift(...events);
|
|
699
976
|
}
|
|
700
977
|
}).then(async () => {
|
|
978
|
+
if (succeeded) {
|
|
979
|
+
clearPendingDeliverIds(events);
|
|
980
|
+
}
|
|
701
981
|
if (succeeded && buffer.length > 0 && !disposed) {
|
|
702
982
|
return runFlush();
|
|
703
983
|
}
|
|
704
984
|
return succeeded;
|
|
705
|
-
}).finally(() => {
|
|
706
|
-
inflightExitBatch = null;
|
|
707
985
|
});
|
|
708
986
|
};
|
|
709
987
|
const flush = () => {
|
|
@@ -724,18 +1002,27 @@ function createTrackingClient(opts) {
|
|
|
724
1002
|
if (!delivered) break;
|
|
725
1003
|
}
|
|
726
1004
|
if (buffer.length > 0) {
|
|
1005
|
+
const droppedCount = buffer.length;
|
|
727
1006
|
if (isDevEnvironment()) {
|
|
728
1007
|
console.warn(
|
|
729
|
-
`[lessonkit] dropped ${
|
|
1008
|
+
`[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
|
|
730
1009
|
);
|
|
731
1010
|
}
|
|
1011
|
+
for (let i = 0; i < droppedCount; i++) {
|
|
1012
|
+
opts?.onBufferDrop?.();
|
|
1013
|
+
}
|
|
732
1014
|
buffer.length = 0;
|
|
1015
|
+
pendingDeliverIds.clear();
|
|
733
1016
|
}
|
|
734
1017
|
};
|
|
735
1018
|
intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
|
|
736
1019
|
intervalId?.unref?.();
|
|
737
1020
|
const track = (event) => {
|
|
738
|
-
if (disposed || disposing) return;
|
|
1021
|
+
if (disposed || disposing) return false;
|
|
1022
|
+
const key = eventDedupKey(event);
|
|
1023
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
1024
|
+
return true;
|
|
1025
|
+
}
|
|
739
1026
|
if (buffer.length >= maxBufferSize) {
|
|
740
1027
|
opts?.onBufferDrop?.();
|
|
741
1028
|
if (!warnedBufferCap && isDevEnvironment()) {
|
|
@@ -744,29 +1031,37 @@ function createTrackingClient(opts) {
|
|
|
744
1031
|
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
|
|
745
1032
|
);
|
|
746
1033
|
}
|
|
747
|
-
return;
|
|
1034
|
+
return false;
|
|
748
1035
|
}
|
|
749
1036
|
buffer.push(event);
|
|
750
1037
|
if (buffer.length >= maxBatchSize) void flush();
|
|
1038
|
+
return true;
|
|
751
1039
|
};
|
|
752
1040
|
return {
|
|
753
1041
|
track,
|
|
754
1042
|
deliver: async (event) => {
|
|
755
|
-
|
|
1043
|
+
const key = eventDedupKey(event);
|
|
1044
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
1045
|
+
return flush();
|
|
1046
|
+
}
|
|
1047
|
+
if (!track(event)) return false;
|
|
1048
|
+
if (key) pendingDeliverIds.add(key);
|
|
756
1049
|
return flush();
|
|
757
1050
|
},
|
|
758
1051
|
flush,
|
|
759
1052
|
flushOnExit: opts?.exitBatchSink ? () => {
|
|
760
|
-
const
|
|
761
|
-
const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
|
|
762
|
-
const events = [...fromInflight, ...fromBuffer];
|
|
1053
|
+
const events = buffer.splice(0, buffer.length);
|
|
763
1054
|
if (!events.length) return;
|
|
764
1055
|
try {
|
|
765
1056
|
const result = opts.exitBatchSink(events);
|
|
766
1057
|
if (result != null && typeof result.catch === "function") {
|
|
767
|
-
void result.
|
|
1058
|
+
void result.then(() => {
|
|
1059
|
+
clearPendingDeliverIds(events);
|
|
1060
|
+
}).catch(() => {
|
|
768
1061
|
buffer.unshift(...events);
|
|
769
1062
|
});
|
|
1063
|
+
} else {
|
|
1064
|
+
clearPendingDeliverIds(events);
|
|
770
1065
|
}
|
|
771
1066
|
} catch {
|
|
772
1067
|
buffer.unshift(...events);
|
|
@@ -788,21 +1083,44 @@ function createTrackingClient(opts) {
|
|
|
788
1083
|
}
|
|
789
1084
|
|
|
790
1085
|
// src/telemetryPipeline.ts
|
|
791
|
-
|
|
792
|
-
|
|
1086
|
+
var LIFECYCLE_TELEMETRY_EVENTS = /* @__PURE__ */ new Set([
|
|
1087
|
+
"course_started",
|
|
1088
|
+
"course_completed",
|
|
1089
|
+
"lesson_started",
|
|
1090
|
+
"lesson_completed",
|
|
1091
|
+
"lesson_time_on_task"
|
|
1092
|
+
]);
|
|
1093
|
+
function isLifecycleTelemetryEvent(name) {
|
|
1094
|
+
return LIFECYCLE_TELEMETRY_EVENTS.has(name);
|
|
1095
|
+
}
|
|
1096
|
+
async function invokeSink(sink, event, emitCtx) {
|
|
1097
|
+
let result;
|
|
1098
|
+
try {
|
|
1099
|
+
result = sink.emit(event, emitCtx);
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (result != null && typeof result.then === "function") {
|
|
1105
|
+
try {
|
|
1106
|
+
await result;
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
793
1111
|
}
|
|
794
1112
|
function createTelemetryPipeline(sinks) {
|
|
795
1113
|
const list = [...sinks];
|
|
796
1114
|
return {
|
|
797
1115
|
sinks: list,
|
|
798
|
-
emit(event, ctx) {
|
|
1116
|
+
async emit(event, ctx) {
|
|
799
1117
|
const emitCtx = ctx ?? {
|
|
800
1118
|
courseId: event.courseId,
|
|
801
1119
|
sessionId: event.sessionId,
|
|
802
1120
|
attemptId: event.attemptId
|
|
803
1121
|
};
|
|
804
1122
|
for (const sink of list) {
|
|
805
|
-
invokeSink(sink, event, emitCtx);
|
|
1123
|
+
await invokeSink(sink, event, emitCtx);
|
|
806
1124
|
}
|
|
807
1125
|
}
|
|
808
1126
|
};
|
|
@@ -841,6 +1159,11 @@ function createProgressController() {
|
|
|
841
1159
|
}
|
|
842
1160
|
return { didComplete: false };
|
|
843
1161
|
}
|
|
1162
|
+
if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
|
|
1163
|
+
console.warn(
|
|
1164
|
+
`[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
844
1167
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
845
1168
|
if (activeLessonId === lessonId) {
|
|
846
1169
|
activeLessonId = void 0;
|
|
@@ -874,6 +1197,20 @@ function warnDuplicatePlugin(id) {
|
|
|
874
1197
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
875
1198
|
console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
|
|
876
1199
|
}
|
|
1200
|
+
function stableUserHash(user) {
|
|
1201
|
+
if (!user) return "";
|
|
1202
|
+
const keys = Object.keys(user).sort();
|
|
1203
|
+
const normalized = {};
|
|
1204
|
+
for (const key of keys) {
|
|
1205
|
+
normalized[key] = user[key];
|
|
1206
|
+
}
|
|
1207
|
+
let h = 0;
|
|
1208
|
+
const serialized = JSON.stringify(normalized);
|
|
1209
|
+
for (let i = 0; i < serialized.length; i++) {
|
|
1210
|
+
h = Math.imul(31, h) + serialized.charCodeAt(i) >>> 0;
|
|
1211
|
+
}
|
|
1212
|
+
return h.toString(36);
|
|
1213
|
+
}
|
|
877
1214
|
function createPluginRegistry(plugins = []) {
|
|
878
1215
|
const registry = /* @__PURE__ */ new Map();
|
|
879
1216
|
for (const plugin of plugins) {
|
|
@@ -915,7 +1252,7 @@ function createPluginRegistry(plugins = []) {
|
|
|
915
1252
|
const composeTrackingSink = (sink, ctxSource) => {
|
|
916
1253
|
if (!sink) return void 0;
|
|
917
1254
|
const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
|
|
918
|
-
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user
|
|
1255
|
+
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${stableUserHash(ctx.user)}`;
|
|
919
1256
|
const layers = [];
|
|
920
1257
|
let composed = sink;
|
|
921
1258
|
for (const plugin of list) {
|
|
@@ -962,6 +1299,10 @@ function resolvePluginHost(plugins) {
|
|
|
962
1299
|
if (Array.isArray(plugins) && plugins.length > 0) return createPluginRegistry(plugins);
|
|
963
1300
|
return null;
|
|
964
1301
|
}
|
|
1302
|
+
function pluginListFingerprint(plugins) {
|
|
1303
|
+
if (!plugins || !Array.isArray(plugins)) return null;
|
|
1304
|
+
return plugins.map((p) => `${p.id}\0${p.version ?? ""}`).join("\n");
|
|
1305
|
+
}
|
|
965
1306
|
function warnRuntimeV1Deprecated() {
|
|
966
1307
|
const g = globalThis;
|
|
967
1308
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
@@ -974,12 +1315,20 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
974
1315
|
const storage = ports.storage ?? createSessionStoragePort();
|
|
975
1316
|
const clock = ports.clock ?? createDefaultClock();
|
|
976
1317
|
const configSnapshot = { ...config };
|
|
1318
|
+
const hasExplicitSessionId = Boolean(configSnapshot.session?.sessionId?.trim());
|
|
1319
|
+
let autoSessionId;
|
|
977
1320
|
let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
|
|
1321
|
+
if (!hasExplicitSessionId) {
|
|
1322
|
+
autoSessionId = sessionId;
|
|
1323
|
+
}
|
|
978
1324
|
let attemptId = configSnapshot.session?.attemptId;
|
|
979
1325
|
let user = configSnapshot.session?.user;
|
|
980
1326
|
let courseId = configSnapshot.courseId;
|
|
1327
|
+
let configuredSessionId = configSnapshot.session?.sessionId;
|
|
981
1328
|
let progress = createProgressController();
|
|
982
1329
|
let pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1330
|
+
let pluginFingerprint = pluginListFingerprint(configSnapshot.plugins);
|
|
1331
|
+
let disposed = false;
|
|
983
1332
|
const getPluginCtx = () => buildPluginContext({
|
|
984
1333
|
courseId,
|
|
985
1334
|
sessionId,
|
|
@@ -990,12 +1339,6 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
990
1339
|
pluginHost?.setupAll(getPluginCtx());
|
|
991
1340
|
}
|
|
992
1341
|
const getSession = () => ({ sessionId, attemptId, user });
|
|
993
|
-
const syncSessionFromConfig = (next) => {
|
|
994
|
-
sessionId = resolveSessionId(storage, next.session?.sessionId);
|
|
995
|
-
attemptId = next.session?.attemptId;
|
|
996
|
-
user = next.session?.user;
|
|
997
|
-
courseId = next.courseId;
|
|
998
|
-
};
|
|
999
1342
|
const applyPluginsToEvent = (event) => {
|
|
1000
1343
|
if (!pluginHost) return event;
|
|
1001
1344
|
return pluginHost.runTelemetry(event, getPluginCtx());
|
|
@@ -1013,28 +1356,23 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1013
1356
|
if (!event) return null;
|
|
1014
1357
|
return applyPluginsToEvent(event);
|
|
1015
1358
|
};
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
if (event === null) return;
|
|
1020
|
-
const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
|
|
1021
|
-
const eventData = "data" in event ? event.data : data;
|
|
1022
|
-
emitFn(event.name, eventData, eventLessonId);
|
|
1023
|
-
};
|
|
1359
|
+
const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
|
|
1360
|
+
const event = buildAndApply(name, data, lessonId);
|
|
1361
|
+
if (event) emitFn(event);
|
|
1024
1362
|
};
|
|
1025
|
-
syncSessionFromConfig(configSnapshot);
|
|
1026
1363
|
const track = (name, data, emit, lessonId) => {
|
|
1364
|
+
if (disposed) return;
|
|
1027
1365
|
const event = buildAndApply(name, data, lessonId);
|
|
1028
1366
|
if (!event) return;
|
|
1029
1367
|
emit(event);
|
|
1030
1368
|
};
|
|
1031
1369
|
const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
|
|
1032
|
-
|
|
1033
|
-
wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1370
|
+
emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1034
1371
|
if (durationMs !== void 0) {
|
|
1035
|
-
|
|
1372
|
+
emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
1036
1373
|
}
|
|
1037
1374
|
};
|
|
1375
|
+
const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
|
|
1038
1376
|
return {
|
|
1039
1377
|
get config() {
|
|
1040
1378
|
return configSnapshot;
|
|
@@ -1047,7 +1385,12 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1047
1385
|
},
|
|
1048
1386
|
getProgressState: () => progress.getState(),
|
|
1049
1387
|
getSession,
|
|
1388
|
+
migrateSessionMarks(fromSessionId, toSessionId) {
|
|
1389
|
+
if (disposed) return;
|
|
1390
|
+
migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
|
|
1391
|
+
},
|
|
1050
1392
|
updateConfig(next) {
|
|
1393
|
+
if (disposed) return;
|
|
1051
1394
|
const previousCourseId = courseId;
|
|
1052
1395
|
const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
|
|
1053
1396
|
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
@@ -1055,35 +1398,69 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1055
1398
|
if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
|
|
1056
1399
|
configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
1057
1400
|
}
|
|
1401
|
+
if (next.autoCompleteOnLessonSwitch !== void 0) {
|
|
1402
|
+
configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
|
|
1403
|
+
}
|
|
1404
|
+
if (next.courseId !== void 0) {
|
|
1405
|
+
courseId = next.courseId;
|
|
1406
|
+
}
|
|
1058
1407
|
if (next.session !== void 0) {
|
|
1408
|
+
const previousSessionId = sessionId;
|
|
1059
1409
|
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
1410
|
+
const explicitSessionId = configSnapshot.session?.sessionId?.trim();
|
|
1411
|
+
if (explicitSessionId) {
|
|
1412
|
+
sessionId = resolveSessionId(storage, explicitSessionId);
|
|
1413
|
+
autoSessionId = void 0;
|
|
1414
|
+
} else {
|
|
1415
|
+
sessionId = autoSessionId ?? resolveSessionId(storage, void 0);
|
|
1416
|
+
if (!autoSessionId) autoSessionId = sessionId;
|
|
1417
|
+
}
|
|
1418
|
+
attemptId = configSnapshot.session?.attemptId;
|
|
1419
|
+
user = configSnapshot.session?.user;
|
|
1420
|
+
if (previousSessionId !== sessionId) {
|
|
1421
|
+
const prevExplicit = configuredSessionId?.trim();
|
|
1422
|
+
const nextExplicit = configSnapshot.session?.sessionId?.trim();
|
|
1423
|
+
const isExplicitLearnerSwap = Boolean(prevExplicit) && Boolean(nextExplicit) && prevExplicit !== nextExplicit;
|
|
1424
|
+
if (!isExplicitLearnerSwap) {
|
|
1425
|
+
migrateCourseStartedMark(storage, previousSessionId, sessionId, courseId);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
configuredSessionId = configSnapshot.session?.sessionId;
|
|
1060
1429
|
}
|
|
1061
|
-
syncSessionFromConfig(configSnapshot);
|
|
1062
1430
|
const sessionKeyAfter = JSON.stringify({ sessionId, attemptId, user });
|
|
1063
1431
|
if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
|
|
1064
1432
|
progress = createProgressController();
|
|
1065
|
-
}
|
|
1066
|
-
if (next.plugins !== void 0 && next.plugins !== configSnapshot.plugins) {
|
|
1067
|
-
pluginHost?.disposeAll();
|
|
1068
|
-
configSnapshot.plugins = next.plugins;
|
|
1069
|
-
pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1070
1433
|
if (!configSnapshot.deferPluginSetup) {
|
|
1434
|
+
pluginHost?.disposeAll();
|
|
1071
1435
|
pluginHost?.setupAll(getPluginCtx());
|
|
1072
1436
|
}
|
|
1437
|
+
}
|
|
1438
|
+
if (next.plugins !== void 0) {
|
|
1439
|
+
const nextFingerprint = pluginListFingerprint(next.plugins);
|
|
1440
|
+
const pluginsChanged = next.plugins !== configSnapshot.plugins || nextFingerprint !== null && nextFingerprint !== pluginFingerprint;
|
|
1441
|
+
if (pluginsChanged) {
|
|
1442
|
+
pluginHost?.disposeAll();
|
|
1443
|
+
configSnapshot.plugins = next.plugins;
|
|
1444
|
+
pluginFingerprint = nextFingerprint;
|
|
1445
|
+
pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1446
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1447
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1073
1450
|
} else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
|
|
1074
1451
|
pluginHost.disposeAll();
|
|
1075
1452
|
pluginHost.setupAll(getPluginCtx());
|
|
1076
1453
|
}
|
|
1077
1454
|
},
|
|
1078
1455
|
setActiveLesson(lessonId, emitFn) {
|
|
1079
|
-
|
|
1456
|
+
if (disposed) return;
|
|
1080
1457
|
const current = progress.getState();
|
|
1081
1458
|
if (current.activeLessonId === lessonId) return;
|
|
1082
1459
|
const previous = current.activeLessonId;
|
|
1083
|
-
if (previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
1460
|
+
if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
1084
1461
|
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
1085
1462
|
if (completed.didComplete) {
|
|
1086
|
-
emitLessonCompletedEvents(previous, completed.durationMs,
|
|
1463
|
+
emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
|
|
1087
1464
|
}
|
|
1088
1465
|
}
|
|
1089
1466
|
if (current.completedLessonIds.has(lessonId)) {
|
|
@@ -1091,39 +1468,50 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1091
1468
|
return;
|
|
1092
1469
|
}
|
|
1093
1470
|
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1094
|
-
|
|
1471
|
+
emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
|
|
1095
1472
|
},
|
|
1096
1473
|
completeLesson(lessonId, emitFn) {
|
|
1474
|
+
if (disposed) return;
|
|
1097
1475
|
completeLessonWithTelemetry({
|
|
1098
1476
|
progress,
|
|
1099
1477
|
lessonId,
|
|
1100
1478
|
nowMs: clock.nowMs(),
|
|
1101
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1479
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
|
|
1102
1480
|
});
|
|
1103
1481
|
},
|
|
1104
1482
|
completeCourse(emitFn) {
|
|
1483
|
+
if (disposed) return;
|
|
1105
1484
|
completeCourseWithTelemetry({
|
|
1106
1485
|
progress,
|
|
1107
1486
|
nowMs: clock.nowMs(),
|
|
1108
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1109
|
-
emitCourseCompleted: () =>
|
|
1487
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
|
|
1488
|
+
emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
|
|
1110
1489
|
});
|
|
1111
1490
|
},
|
|
1112
1491
|
track,
|
|
1113
1492
|
scoreAssessment(input, lessonId) {
|
|
1114
|
-
if (!pluginHost) return null;
|
|
1493
|
+
if (disposed || !pluginHost) return null;
|
|
1115
1494
|
return pluginHost.scoreAssessment(
|
|
1116
1495
|
{ ...input, lessonId: input.lessonId ?? lessonId },
|
|
1117
1496
|
getPluginCtx()
|
|
1118
1497
|
);
|
|
1119
1498
|
},
|
|
1120
1499
|
resetForCourseChange(nextCourseId) {
|
|
1500
|
+
if (disposed) return;
|
|
1121
1501
|
configSnapshot.courseId = nextCourseId;
|
|
1122
1502
|
courseId = nextCourseId;
|
|
1123
1503
|
progress = createProgressController();
|
|
1504
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1505
|
+
pluginHost?.disposeAll();
|
|
1506
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1507
|
+
}
|
|
1124
1508
|
},
|
|
1125
1509
|
dispose() {
|
|
1126
|
-
|
|
1510
|
+
if (disposed) return;
|
|
1511
|
+
disposed = true;
|
|
1512
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1513
|
+
pluginHost?.disposeAll();
|
|
1514
|
+
}
|
|
1127
1515
|
}
|
|
1128
1516
|
};
|
|
1129
1517
|
}
|
|
@@ -1142,12 +1530,16 @@ export {
|
|
|
1142
1530
|
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1143
1531
|
ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
1144
1532
|
BLOCKS_14_PAGE_SLIDE,
|
|
1533
|
+
BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
1534
|
+
BRANCH_NODE_ALLOWED_CHILD_TYPES,
|
|
1145
1535
|
COMPOUND_MAX_NESTING_DEPTH,
|
|
1146
1536
|
COMPOUND_RESUME_SCHEMA_VERSION,
|
|
1537
|
+
GAME_MAP_ALLOWED_CHILD_TYPES,
|
|
1147
1538
|
ID_MAX_LENGTH,
|
|
1148
1539
|
ID_PATTERN,
|
|
1149
1540
|
INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
|
|
1150
1541
|
INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
|
|
1542
|
+
MAP_STAGE_ALLOWED_CHILD_TYPES,
|
|
1151
1543
|
PAGE_ALLOWED_CHILD_TYPES,
|
|
1152
1544
|
SESSION_STORAGE_KEY,
|
|
1153
1545
|
SLIDE_ALLOWED_CHILD_TYPES,
|
|
@@ -1191,11 +1583,14 @@ export {
|
|
|
1191
1583
|
hasCourseStarted,
|
|
1192
1584
|
hasCourseStartedEmittedToTracking,
|
|
1193
1585
|
hasCourseStartedPipelineDelivered,
|
|
1586
|
+
hasCourseStartedXapiSent,
|
|
1194
1587
|
isChildTypeAllowed,
|
|
1588
|
+
isLifecycleTelemetryEvent,
|
|
1195
1589
|
loadCompoundState,
|
|
1196
1590
|
markCourseStarted,
|
|
1197
1591
|
markCourseStartedEmittedToTracking,
|
|
1198
1592
|
markCourseStartedPipelineDelivered,
|
|
1593
|
+
markCourseStartedXapiSent,
|
|
1199
1594
|
migrateCourseStartedMark,
|
|
1200
1595
|
nowIso,
|
|
1201
1596
|
parseBlockId,
|
|
@@ -1214,5 +1609,6 @@ export {
|
|
|
1214
1609
|
telemetryCatalogVersion,
|
|
1215
1610
|
tryBuildTelemetryEvent,
|
|
1216
1611
|
tryEmitCourseStarted,
|
|
1612
|
+
validateBranchGraph,
|
|
1217
1613
|
validateId
|
|
1218
1614
|
};
|