musora-content-services 2.95.5 → 2.96.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/index.d.ts +7 -1
- package/src/index.js +7 -1
- package/src/services/content/artist.ts +6 -1
- package/src/services/content/genre.ts +6 -1
- package/src/services/content/instructor.ts +1 -1
- package/src/services/content-org/learning-paths.ts +66 -31
- package/src/services/contentProgress.js +144 -61
- package/src/services/progress-events.js +53 -1
- package/src/services/sync/fetch.ts +1 -6
- package/src/services/sync/manager.ts +32 -16
- package/src/services/sync/models/ContentProgress.ts +9 -6
- package/src/services/sync/repositories/content-progress.ts +22 -26
- package/src/services/sync/store/index.ts +1 -1
- package/test/initializeTests.js +4 -2
- package/test/learningPaths.test.js +59 -10
- package/test/sync/adapter.ts +41 -0
- package/test/sync/initialize-sync-manager.js +104 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { fetchHierarchy, fetchLearningPathHierarchy } from './sanity.js'
|
|
2
2
|
import { db } from './sync'
|
|
3
|
-
import {COLLECTION_TYPE, STATE} from './sync/models/ContentProgress'
|
|
3
|
+
import { COLLECTION_TYPE, STATE } from './sync/models/ContentProgress'
|
|
4
4
|
import { trackUserPractice, findIncompleteLesson } from './userActivity'
|
|
5
5
|
import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
|
|
6
|
+
import { emitContentCompleted } from './progress-events'
|
|
6
7
|
|
|
7
8
|
const STATE_STARTED = STATE.STARTED
|
|
8
9
|
const STATE_COMPLETED = STATE.COMPLETED
|
|
@@ -17,7 +18,12 @@ export async function getProgressStateByIds(contentIds, collection = null) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export async function getResumeTimeSecondsByIds(contentIds, collection = null) {
|
|
20
|
-
return getByIds(
|
|
21
|
+
return getByIds(
|
|
22
|
+
normalizeContentIds(contentIds),
|
|
23
|
+
normalizeCollection(collection),
|
|
24
|
+
'resume_time_seconds',
|
|
25
|
+
0
|
|
26
|
+
)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
export async function getNavigateTo(data, collection = null) {
|
|
@@ -54,23 +60,45 @@ export async function getNavigateTo(data, collection = null) {
|
|
|
54
60
|
const firstChild = validChildren[0]
|
|
55
61
|
let lastInteractedChildNavToData = await getNavigateTo([firstChild], collection)
|
|
56
62
|
lastInteractedChildNavToData = lastInteractedChildNavToData[firstChild.id] ?? null
|
|
57
|
-
navigateToData[content.id] = buildNavigateTo(
|
|
63
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
64
|
+
firstChild,
|
|
65
|
+
lastInteractedChildNavToData,
|
|
66
|
+
collection
|
|
67
|
+
) //no G-child for LP
|
|
58
68
|
} else {
|
|
59
69
|
const childrenStates = await getProgressStateByIds(childrenIds, collection)
|
|
60
70
|
const lastInteracted = await getLastInteractedOf(childrenIds, collection)
|
|
61
71
|
const lastInteractedStatus = childrenStates[lastInteracted]
|
|
62
72
|
|
|
63
73
|
if (['course', 'pack-bundle', 'skill-pack'].includes(content.type)) {
|
|
64
|
-
if (lastInteractedStatus === STATE_STARTED) {
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
if (lastInteractedStatus === STATE_STARTED) {
|
|
75
|
+
// send to last interacted
|
|
76
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
77
|
+
children.get(lastInteracted),
|
|
78
|
+
null,
|
|
79
|
+
collection
|
|
80
|
+
)
|
|
81
|
+
} else {
|
|
82
|
+
// send to first incomplete after last interacted
|
|
67
83
|
let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
|
|
68
|
-
navigateToData[content.id] = buildNavigateTo(
|
|
84
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
85
|
+
children.get(incompleteChild),
|
|
86
|
+
null,
|
|
87
|
+
collection
|
|
88
|
+
)
|
|
69
89
|
}
|
|
70
|
-
} else if (
|
|
90
|
+
} else if (
|
|
91
|
+
['song-tutorial', 'guided-course', COLLECTION_TYPE.LEARNING_PATH].includes(content.type)
|
|
92
|
+
) {
|
|
93
|
+
// send to first incomplete
|
|
71
94
|
let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
|
|
72
|
-
navigateToData[content.id] = buildNavigateTo(
|
|
73
|
-
|
|
95
|
+
navigateToData[content.id] = buildNavigateTo(
|
|
96
|
+
children.get(incompleteChild),
|
|
97
|
+
null,
|
|
98
|
+
collection
|
|
99
|
+
)
|
|
100
|
+
} else if (twoDepthContentTypes.includes(content.type)) {
|
|
101
|
+
// send to navigateTo child of last interacted child
|
|
74
102
|
const firstChildren = content.children ?? []
|
|
75
103
|
const lastInteractedChildId = await getLastInteractedOf(
|
|
76
104
|
firstChildren.map((child) => child.id),
|
|
@@ -117,21 +145,28 @@ function buildNavigateTo(content, child = null, collection = null) {
|
|
|
117
145
|
* @returns {Promise<number>}
|
|
118
146
|
*/
|
|
119
147
|
export async function getLastInteractedOf(contentIds, collection = null) {
|
|
120
|
-
return db.contentProgress
|
|
148
|
+
return db.contentProgress
|
|
149
|
+
.mostRecentlyUpdatedId(normalizeContentIds(contentIds), normalizeCollection(collection))
|
|
150
|
+
.then((r) => (r.data ? parseInt(r.data) : undefined))
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
export async function getProgressDataByIds(contentIds, collection) {
|
|
124
154
|
contentIds = normalizeContentIds(contentIds)
|
|
125
155
|
collection = normalizeCollection(collection)
|
|
126
156
|
|
|
127
|
-
const progress = Object.fromEntries(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
const progress = Object.fromEntries(
|
|
158
|
+
contentIds.map((id) => [
|
|
159
|
+
id,
|
|
160
|
+
{
|
|
161
|
+
last_update: 0,
|
|
162
|
+
progress: 0,
|
|
163
|
+
status: '',
|
|
164
|
+
},
|
|
165
|
+
])
|
|
166
|
+
)
|
|
132
167
|
|
|
133
|
-
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
|
|
134
|
-
r.data.forEach(p => {
|
|
168
|
+
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
|
|
169
|
+
r.data.forEach((p) => {
|
|
135
170
|
progress[p.content_id] = {
|
|
136
171
|
last_update: p.updated_at,
|
|
137
172
|
progress: p.progress_percent,
|
|
@@ -145,15 +180,17 @@ export async function getProgressDataByIds(contentIds, collection) {
|
|
|
145
180
|
|
|
146
181
|
async function getById(contentId, dataKey, defaultValue) {
|
|
147
182
|
if (!contentId) return defaultValue
|
|
148
|
-
return db.contentProgress
|
|
183
|
+
return db.contentProgress
|
|
184
|
+
.getOneProgressByContentId(contentId)
|
|
185
|
+
.then((r) => r.data?.[dataKey] ?? defaultValue)
|
|
149
186
|
}
|
|
150
187
|
|
|
151
188
|
async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
152
189
|
if (contentIds.length === 0) return {}
|
|
153
190
|
|
|
154
|
-
const progress = Object.fromEntries(contentIds.map(id => [id, defaultValue]))
|
|
155
|
-
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
|
|
156
|
-
r.data.forEach(p => {
|
|
191
|
+
const progress = Object.fromEntries(contentIds.map((id) => [id, defaultValue]))
|
|
192
|
+
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
|
|
193
|
+
r.data.forEach((p) => {
|
|
157
194
|
progress[p.content_id] = p[dataKey] ?? defaultValue
|
|
158
195
|
})
|
|
159
196
|
})
|
|
@@ -161,11 +198,11 @@ async function getByIds(contentIds, collection, dataKey, defaultValue) {
|
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
export async function getAllStarted(limit = null) {
|
|
164
|
-
return db.contentProgress.startedIds(limit).then(r => r.data.map(id => parseInt(id)))
|
|
201
|
+
return db.contentProgress.startedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
export async function getAllCompleted(limit = null) {
|
|
168
|
-
return db.contentProgress.completedIds(limit).then(r => r.data.map(id => parseInt(id)))
|
|
205
|
+
return db.contentProgress.completedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
|
|
169
206
|
}
|
|
170
207
|
|
|
171
208
|
export async function getAllCompletedByIds(contentIds) {
|
|
@@ -175,7 +212,7 @@ export async function getAllCompletedByIds(contentIds) {
|
|
|
175
212
|
export async function getAllStartedOrCompleted({
|
|
176
213
|
onlyIds = true,
|
|
177
214
|
brand = null,
|
|
178
|
-
limit = null
|
|
215
|
+
limit = null,
|
|
179
216
|
} = {}) {
|
|
180
217
|
const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
|
|
181
218
|
const filters = {
|
|
@@ -185,15 +222,22 @@ export async function getAllStartedOrCompleted({
|
|
|
185
222
|
}
|
|
186
223
|
|
|
187
224
|
if (onlyIds) {
|
|
188
|
-
return db.contentProgress
|
|
225
|
+
return db.contentProgress
|
|
226
|
+
.startedOrCompletedIds(filters)
|
|
227
|
+
.then((r) => r.data.map((id) => parseInt(id)))
|
|
189
228
|
} else {
|
|
190
|
-
return db.contentProgress.startedOrCompleted(filters).then(r => {
|
|
191
|
-
return Object.fromEntries(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
229
|
+
return db.contentProgress.startedOrCompleted(filters).then((r) => {
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
r.data.map((p) => [
|
|
232
|
+
p.content_id,
|
|
233
|
+
{
|
|
234
|
+
last_update: p.updated_at,
|
|
235
|
+
progress: p.progress_percent,
|
|
236
|
+
status: p.state,
|
|
237
|
+
brand: p.content_brand,
|
|
238
|
+
},
|
|
239
|
+
])
|
|
240
|
+
)
|
|
197
241
|
})
|
|
198
242
|
}
|
|
199
243
|
}
|
|
@@ -216,8 +260,8 @@ export async function getAllStartedOrCompleted({
|
|
|
216
260
|
* console.log(progressMap[123]); // => 52
|
|
217
261
|
*/
|
|
218
262
|
export async function getStartedOrCompletedProgressOnly({ brand = undefined } = {}) {
|
|
219
|
-
return db.contentProgress.startedOrCompleted({ brand: brand }).then(r => {
|
|
220
|
-
return Object.fromEntries(r.data.map(p => [p.content_id, p.progress_percent]))
|
|
263
|
+
return db.contentProgress.startedOrCompleted({ brand: brand }).then((r) => {
|
|
264
|
+
return Object.fromEntries(r.data.map((p) => [p.content_id, p.progress_percent]))
|
|
221
265
|
})
|
|
222
266
|
}
|
|
223
267
|
|
|
@@ -256,9 +300,7 @@ export async function recordWatchSession(
|
|
|
256
300
|
async function trackPractice(contentId, secondsPlayed, prevSession, details = {}) {
|
|
257
301
|
const session = prevSession || new Map()
|
|
258
302
|
|
|
259
|
-
const secondsSinceLastUpdate = Math.ceil(
|
|
260
|
-
secondsPlayed - (session.get(contentId) ?? 0)
|
|
261
|
-
)
|
|
303
|
+
const secondsSinceLastUpdate = Math.ceil(secondsPlayed - (session.get(contentId) ?? 0))
|
|
262
304
|
session.set(contentId, secondsPlayed)
|
|
263
305
|
|
|
264
306
|
await trackUserPractice(contentId, secondsSinceLastUpdate, details)
|
|
@@ -274,43 +316,78 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
|
|
|
274
316
|
}
|
|
275
317
|
|
|
276
318
|
export async function contentStatusCompleted(contentId, collection = null) {
|
|
277
|
-
return setStartedOrCompletedStatus(
|
|
319
|
+
return setStartedOrCompletedStatus(
|
|
320
|
+
normalizeContentId(contentId),
|
|
321
|
+
normalizeCollection(collection),
|
|
322
|
+
true
|
|
323
|
+
)
|
|
278
324
|
}
|
|
279
325
|
|
|
280
326
|
export async function contentsStatusCompleted(contentIds, collection = null) {
|
|
281
|
-
return setStartedOrCompletedStatuses(
|
|
327
|
+
return setStartedOrCompletedStatuses(
|
|
328
|
+
normalizeContentIds(contentIds),
|
|
329
|
+
normalizeCollection(collection),
|
|
330
|
+
true
|
|
331
|
+
)
|
|
282
332
|
}
|
|
283
333
|
|
|
284
334
|
export async function contentStatusStarted(contentId, collection = null) {
|
|
285
|
-
return setStartedOrCompletedStatus(
|
|
335
|
+
return setStartedOrCompletedStatus(
|
|
336
|
+
normalizeContentId(contentId),
|
|
337
|
+
normalizeCollection(collection),
|
|
338
|
+
false
|
|
339
|
+
)
|
|
286
340
|
}
|
|
287
341
|
export async function contentStatusReset(contentId, collection = null) {
|
|
288
342
|
return resetStatus(contentId, collection)
|
|
289
343
|
}
|
|
290
344
|
|
|
291
345
|
async function saveContentProgress(contentId, collection, progress, currentSeconds) {
|
|
292
|
-
const response = await db.contentProgress.recordProgress(
|
|
346
|
+
const response = await db.contentProgress.recordProgress(
|
|
347
|
+
contentId,
|
|
348
|
+
collection,
|
|
349
|
+
progress,
|
|
350
|
+
currentSeconds
|
|
351
|
+
)
|
|
352
|
+
if (progress === 100) emitContentCompleted(contentId, collection)
|
|
293
353
|
|
|
294
354
|
// note - previous implementation explicitly did not trickle progress to children here
|
|
295
355
|
// (only to siblings/parents via le bubbles)
|
|
296
356
|
|
|
297
|
-
const bubbledProgresses = await bubbleProgress(
|
|
357
|
+
const bubbledProgresses = await bubbleProgress(
|
|
358
|
+
await getHierarchy(contentId, collection),
|
|
359
|
+
contentId,
|
|
360
|
+
collection
|
|
361
|
+
)
|
|
298
362
|
await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
|
|
299
363
|
|
|
364
|
+
for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
|
|
365
|
+
if (bubbledProgress === 100) {
|
|
366
|
+
emitContentCompleted(Number(bubbledContentId), collection)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
300
369
|
return response
|
|
301
370
|
}
|
|
302
371
|
|
|
303
372
|
async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
|
|
304
373
|
const progress = isCompleted ? 100 : 0
|
|
305
374
|
const response = await db.contentProgress.recordProgress(contentId, collection, progress)
|
|
306
|
-
|
|
375
|
+
if (progress === 100) emitContentCompleted(contentId, collection)
|
|
307
376
|
const hierarchy = await getHierarchy(contentId, collection)
|
|
308
|
-
|
|
309
377
|
await Promise.all([
|
|
310
|
-
db.contentProgress.recordProgressesTentative(
|
|
311
|
-
|
|
378
|
+
db.contentProgress.recordProgressesTentative(
|
|
379
|
+
trickleProgress(hierarchy, contentId, collection, progress),
|
|
380
|
+
collection
|
|
381
|
+
),
|
|
382
|
+
bubbleProgress(hierarchy, contentId, collection).then(async (bubbledProgresses) => {
|
|
383
|
+
await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
|
|
384
|
+
for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
|
|
385
|
+
if (bubbledProgress === 100) {
|
|
386
|
+
emitContentCompleted(Number(bubbledContentId), collection)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}),
|
|
312
390
|
])
|
|
313
|
-
|
|
314
391
|
return response
|
|
315
392
|
}
|
|
316
393
|
|
|
@@ -337,13 +414,11 @@ async function setStartedOrCompletedStatuses(contentIds, collection, isCompleted
|
|
|
337
414
|
ids = {
|
|
338
415
|
...ids,
|
|
339
416
|
...trickleProgress(hierarchy, contentId, collection, progress),
|
|
340
|
-
...await bubbleProgress(hierarchy, contentId, collection)
|
|
417
|
+
...(await bubbleProgress(hierarchy, contentId, collection)),
|
|
341
418
|
}
|
|
342
419
|
}
|
|
343
420
|
|
|
344
|
-
await Promise.all([
|
|
345
|
-
db.contentProgress.recordProgressesTentative(ids, collection),
|
|
346
|
-
]);
|
|
421
|
+
await Promise.all([db.contentProgress.recordProgressesTentative(ids, collection)])
|
|
347
422
|
|
|
348
423
|
return response
|
|
349
424
|
}
|
|
@@ -353,8 +428,13 @@ async function resetStatus(contentId, collection = null) {
|
|
|
353
428
|
const hierarchy = await getHierarchy(contentId, collection)
|
|
354
429
|
|
|
355
430
|
await Promise.all([
|
|
356
|
-
db.contentProgress.recordProgressesTentative(
|
|
357
|
-
|
|
431
|
+
db.contentProgress.recordProgressesTentative(
|
|
432
|
+
trickleProgress(hierarchy, contentId, collection, 0),
|
|
433
|
+
collection
|
|
434
|
+
),
|
|
435
|
+
bubbleProgress(hierarchy, contentId, collection).then((bubbledProgresses) =>
|
|
436
|
+
db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
|
|
437
|
+
),
|
|
358
438
|
])
|
|
359
439
|
|
|
360
440
|
return response
|
|
@@ -364,10 +444,10 @@ async function resetStatus(contentId, collection = null) {
|
|
|
364
444
|
// as long as callers remember to pass collection where needed
|
|
365
445
|
function trickleProgress(hierarchy, contentId, _collection, progress) {
|
|
366
446
|
const descendantIds = getChildrenToDepth(contentId, hierarchy, MAX_DEPTH)
|
|
367
|
-
return Object.fromEntries(descendantIds.map(id => [id, progress]))
|
|
447
|
+
return Object.fromEntries(descendantIds.map((id) => [id, progress]))
|
|
368
448
|
}
|
|
369
449
|
|
|
370
|
-
async function bubbleProgress(hierarchy, contentId, collection = null)
|
|
450
|
+
async function bubbleProgress(hierarchy, contentId, collection = null) {
|
|
371
451
|
const ids = getAncestorAndSiblingIds(hierarchy, contentId)
|
|
372
452
|
const progresses = await getByIds(ids, collection, 'progress_percent', 0)
|
|
373
453
|
return averageProgressesFor(hierarchy, contentId, progresses)
|
|
@@ -386,7 +466,7 @@ function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
|
|
|
386
466
|
|
|
387
467
|
return [
|
|
388
468
|
...(hierarchy?.children?.[parentId] ?? []),
|
|
389
|
-
...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1)
|
|
469
|
+
...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1),
|
|
390
470
|
]
|
|
391
471
|
}
|
|
392
472
|
|
|
@@ -398,10 +478,13 @@ function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
|
|
|
398
478
|
const parentId = hierarchy?.parents?.[contentId]
|
|
399
479
|
if (!parentId) return {}
|
|
400
480
|
|
|
401
|
-
const parentChildProgress = hierarchy?.children?.[parentId]?.map(childId => {
|
|
481
|
+
const parentChildProgress = hierarchy?.children?.[parentId]?.map((childId) => {
|
|
402
482
|
return progressData[childId] ?? 0
|
|
403
483
|
})
|
|
404
|
-
const avgParentProgress =
|
|
484
|
+
const avgParentProgress =
|
|
485
|
+
parentChildProgress.length > 0
|
|
486
|
+
? Math.round(parentChildProgress.reduce((a, b) => a + b, 0) / parentChildProgress.length)
|
|
487
|
+
: 0
|
|
405
488
|
|
|
406
489
|
return {
|
|
407
490
|
...averageProgressesFor(hierarchy, parentId, progressData, depth + 1),
|
|
@@ -426,7 +509,7 @@ function normalizeContentId(contentId) {
|
|
|
426
509
|
}
|
|
427
510
|
|
|
428
511
|
function normalizeContentIds(contentIds) {
|
|
429
|
-
return contentIds.map(id => normalizeContentId(id))
|
|
512
|
+
return contentIds.map((id) => normalizeContentId(id))
|
|
430
513
|
}
|
|
431
514
|
|
|
432
515
|
function normalizeCollection(collection) {
|
|
@@ -435,7 +518,7 @@ function normalizeCollection(collection) {
|
|
|
435
518
|
if (!Object.values(COLLECTION_TYPE).includes(collection.type)) {
|
|
436
519
|
throw new Error(`Invalid collection type: ${collection.type}`)
|
|
437
520
|
}
|
|
438
|
-
|
|
521
|
+
|
|
439
522
|
if (typeof collection.id === 'string' && isNaN(+collection.id)) {
|
|
440
523
|
throw new Error(`Invalid collection id: ${collection.id}`)
|
|
441
524
|
}
|
|
@@ -48,7 +48,7 @@ export function onProgressSaved(listener) {
|
|
|
48
48
|
* @returns {void}
|
|
49
49
|
*/
|
|
50
50
|
export function emitProgressSaved(event) {
|
|
51
|
-
listeners.forEach(listener => {
|
|
51
|
+
listeners.forEach((listener) => {
|
|
52
52
|
try {
|
|
53
53
|
listener(event)
|
|
54
54
|
} catch (error) {
|
|
@@ -56,3 +56,55 @@ export function emitProgressSaved(event) {
|
|
|
56
56
|
}
|
|
57
57
|
})
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {Object} ContentCompletedEvent
|
|
62
|
+
* @property {number} contentId - Railcontent ID of the completed content item
|
|
63
|
+
* @property {Object|null} collection - Collection context information
|
|
64
|
+
* @property {string} collection.type - Collection type (learning-path, guided-course, etc.)
|
|
65
|
+
* @property {number} collection.id - Collection ID
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @callback ContentCompletedListener
|
|
70
|
+
* @param {ContentCompletedEvent} event - The content completion event data
|
|
71
|
+
* @returns {void}
|
|
72
|
+
*/
|
|
73
|
+
const completedListeners = new Set()
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {ContentCompletedListener} listener - Function called when content is completed
|
|
77
|
+
* @returns {function(): void} Cleanup function to unregister the listener
|
|
78
|
+
*
|
|
79
|
+
* @example Listen for content completion
|
|
80
|
+
* const cleanup = onContentCompleted((event) => {
|
|
81
|
+
* console.log(`Content ${event.contentId} completed!`)
|
|
82
|
+
* if (event.collection) {
|
|
83
|
+
* console.log(`Within ${event.collection.type}: ${event.collection.id}`)
|
|
84
|
+
* checkCollectionProgress(event.collection.id)
|
|
85
|
+
* }
|
|
86
|
+
* })
|
|
87
|
+
*
|
|
88
|
+
* // Later, when no longer needed:
|
|
89
|
+
* cleanup()
|
|
90
|
+
*/
|
|
91
|
+
export function onContentCompleted(listener) {
|
|
92
|
+
completedListeners.add(listener)
|
|
93
|
+
return () => completedListeners.delete(listener)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {number} contentId - The ID of the completed content item
|
|
98
|
+
* @param {Object|null} collection - Collection context information
|
|
99
|
+
* @returns {void}
|
|
100
|
+
*/
|
|
101
|
+
export function emitContentCompleted(contentId, collection) {
|
|
102
|
+
const event = { contentId: contentId, collection: collection }
|
|
103
|
+
completedListeners.forEach((listener) => {
|
|
104
|
+
try {
|
|
105
|
+
listener(event)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Error in contentCompleted listener:', error)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
@@ -308,12 +308,7 @@ function serializeIds(ids: { id: RecordId }): { client_record_id: RecordId } {
|
|
|
308
308
|
|
|
309
309
|
function deserializeRecord(record: SyncSyncable<BaseModel, 'client_record_id'> | null): SyncSyncable<BaseModel, 'id'> | null {
|
|
310
310
|
if (record) {
|
|
311
|
-
const { client_record_id: id, ...rest } = record
|
|
312
|
-
|
|
313
|
-
if ('collection_type' in rest && rest.collection_type === 'self') {
|
|
314
|
-
rest.collection_type = null
|
|
315
|
-
rest.collection_id = null
|
|
316
|
-
}
|
|
311
|
+
const { client_record_id: id, ...rest } = record
|
|
317
312
|
|
|
318
313
|
return {
|
|
319
314
|
...rest,
|
|
@@ -14,6 +14,9 @@ import { inBoundary } from './errors/boundary'
|
|
|
14
14
|
import createStoresFromConfig from './store-configs'
|
|
15
15
|
import { contentProgressObserver } from '../awards/internal/content-progress-observer'
|
|
16
16
|
|
|
17
|
+
import { onProgressSaved, onContentCompleted } from '../progress-events'
|
|
18
|
+
import { onContentCompletedLearningPathListener } from '../content-org/learning-paths'
|
|
19
|
+
|
|
17
20
|
export default class SyncManager {
|
|
18
21
|
private static counter = 0
|
|
19
22
|
private static instance: SyncManager | null = null
|
|
@@ -30,6 +33,10 @@ export default class SyncManager {
|
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
public static getInstanceOrNull() {
|
|
37
|
+
return SyncManager.instance
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
public static getInstance(): SyncManager {
|
|
34
41
|
if (!SyncManager.instance) {
|
|
35
42
|
throw new SyncError('SyncManager not initialized')
|
|
@@ -72,17 +79,26 @@ export default class SyncManager {
|
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
|
|
75
|
-
return new SyncStore<TModel>(
|
|
82
|
+
return new SyncStore<TModel>(
|
|
83
|
+
config,
|
|
84
|
+
this.context,
|
|
85
|
+
this.database,
|
|
86
|
+
this.retry,
|
|
87
|
+
this.runScope,
|
|
88
|
+
this.telemetry
|
|
89
|
+
)
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
registerStores<TModel extends BaseModel>(stores: SyncStore<TModel>[]) {
|
|
79
|
-
return Object.fromEntries(
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
stores.map((store) => {
|
|
95
|
+
return [store.model.table, store]
|
|
96
|
+
})
|
|
97
|
+
) as Record<string, SyncStore<TModel>>
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
storesForModels(models: ModelClass[]) {
|
|
85
|
-
return models.map(model => this.storesRegistry[model.table])
|
|
101
|
+
return models.map((model) => this.storesRegistry[model.table])
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
createStrategy<T extends SyncStrategy, U extends any[]>(
|
|
@@ -96,11 +112,8 @@ export default class SyncManager {
|
|
|
96
112
|
this.strategyMap.push({ stores, strategies })
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
protectStores(
|
|
100
|
-
|
|
101
|
-
mechanisms: SyncConcurrencySafetyMechanism[]
|
|
102
|
-
) {
|
|
103
|
-
const teardowns = mechanisms.map(mechanism => mechanism(this.context, stores))
|
|
115
|
+
protectStores(stores: SyncStore<any>[], mechanisms: SyncConcurrencySafetyMechanism[]) {
|
|
116
|
+
const teardowns = mechanisms.map((mechanism) => mechanism(this.context, stores))
|
|
104
117
|
this.safetyMap.push({ stores, mechanisms: teardowns })
|
|
105
118
|
}
|
|
106
119
|
|
|
@@ -111,9 +124,9 @@ export default class SyncManager {
|
|
|
111
124
|
this.retry.start()
|
|
112
125
|
|
|
113
126
|
this.strategyMap.forEach(({ stores, strategies }) => {
|
|
114
|
-
strategies.forEach(strategy => {
|
|
115
|
-
stores.forEach(store => {
|
|
116
|
-
strategy.onTrigger(store, reason => {
|
|
127
|
+
strategies.forEach((strategy) => {
|
|
128
|
+
stores.forEach((store) => {
|
|
129
|
+
strategy.onTrigger(store, (reason) => {
|
|
117
130
|
store.requestSync(reason)
|
|
118
131
|
})
|
|
119
132
|
})
|
|
@@ -121,15 +134,18 @@ export default class SyncManager {
|
|
|
121
134
|
})
|
|
122
135
|
})
|
|
123
136
|
|
|
124
|
-
contentProgressObserver.start(this.database).catch(error => {
|
|
137
|
+
contentProgressObserver.start(this.database).catch((error) => {
|
|
125
138
|
this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
|
|
126
139
|
})
|
|
140
|
+
onContentCompleted(onContentCompletedLearningPathListener)
|
|
127
141
|
|
|
128
142
|
const teardown = async () => {
|
|
129
143
|
this.telemetry.debug('[SyncManager] Tearing down')
|
|
130
144
|
this.runScope.abort()
|
|
131
|
-
this.strategyMap.forEach(({ strategies }) =>
|
|
132
|
-
|
|
145
|
+
this.strategyMap.forEach(({ strategies }) =>
|
|
146
|
+
strategies.forEach((strategy) => strategy.stop())
|
|
147
|
+
)
|
|
148
|
+
this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach((mechanism) => mechanism()))
|
|
133
149
|
contentProgressObserver.stop()
|
|
134
150
|
this.retry.stop()
|
|
135
151
|
this.context.stop()
|
|
@@ -2,9 +2,12 @@ import BaseModel from './Base'
|
|
|
2
2
|
import { SYNC_TABLES } from '../schema'
|
|
3
3
|
|
|
4
4
|
export enum COLLECTION_TYPE {
|
|
5
|
+
SELF = 'self',
|
|
6
|
+
GUIDED_COURSE = 'guided-course',
|
|
5
7
|
LEARNING_PATH = 'learning-path-v2',
|
|
6
8
|
PLAYLIST = 'playlist',
|
|
7
9
|
}
|
|
10
|
+
export const COLLECTION_ID_SELF = 0
|
|
8
11
|
|
|
9
12
|
export enum STATE {
|
|
10
13
|
STARTED = 'started',
|
|
@@ -13,8 +16,8 @@ export enum STATE {
|
|
|
13
16
|
|
|
14
17
|
export default class ContentProgress extends BaseModel<{
|
|
15
18
|
content_id: number
|
|
16
|
-
collection_type: COLLECTION_TYPE
|
|
17
|
-
collection_id: number
|
|
19
|
+
collection_type: COLLECTION_TYPE
|
|
20
|
+
collection_id: number
|
|
18
21
|
state: STATE
|
|
19
22
|
progress_percent: number
|
|
20
23
|
resume_time_seconds: number | null
|
|
@@ -34,10 +37,10 @@ export default class ContentProgress extends BaseModel<{
|
|
|
34
37
|
return this._getRaw('progress_percent') as number
|
|
35
38
|
}
|
|
36
39
|
get collection_type() {
|
|
37
|
-
return
|
|
40
|
+
return this._getRaw('collection_type') as COLLECTION_TYPE
|
|
38
41
|
}
|
|
39
42
|
get collection_id() {
|
|
40
|
-
return
|
|
43
|
+
return this._getRaw('collection_id') as number
|
|
41
44
|
}
|
|
42
45
|
get resume_time_seconds() {
|
|
43
46
|
return (this._getRaw('resume_time_seconds') as number) || null
|
|
@@ -55,10 +58,10 @@ export default class ContentProgress extends BaseModel<{
|
|
|
55
58
|
set progress_percent(value: number) {
|
|
56
59
|
this._setRaw('progress_percent', Math.min(100, Math.max(0, value)))
|
|
57
60
|
}
|
|
58
|
-
set collection_type(value: COLLECTION_TYPE
|
|
61
|
+
set collection_type(value: COLLECTION_TYPE) {
|
|
59
62
|
this._setRaw('collection_type', value)
|
|
60
63
|
}
|
|
61
|
-
set collection_id(value: number
|
|
64
|
+
set collection_id(value: number) {
|
|
62
65
|
this._setRaw('collection_id', value)
|
|
63
66
|
}
|
|
64
67
|
set resume_time_seconds(value: number | null) {
|