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,12 +1,12 @@
|
|
|
1
1
|
import SyncRepository, { Q } from './base'
|
|
2
|
-
import ContentProgress, { COLLECTION_TYPE, STATE } from '../models/ContentProgress'
|
|
2
|
+
import ContentProgress, { COLLECTION_TYPE, COLLECTION_ID_SELF, STATE } from '../models/ContentProgress'
|
|
3
3
|
|
|
4
4
|
export default class ProgressRepository extends SyncRepository<ContentProgress> {
|
|
5
5
|
// null collection only
|
|
6
6
|
async startedIds(limit?: number) {
|
|
7
7
|
return this.queryAllIds(...[
|
|
8
|
-
Q.where('collection_type',
|
|
9
|
-
Q.where('collection_id',
|
|
8
|
+
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
9
|
+
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
10
10
|
|
|
11
11
|
Q.where('state', STATE.STARTED),
|
|
12
12
|
Q.sortBy('updated_at', 'desc'),
|
|
@@ -18,8 +18,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
18
18
|
// null collection only
|
|
19
19
|
async completedIds(limit?: number) {
|
|
20
20
|
return this.queryAllIds(...[
|
|
21
|
-
Q.where('collection_type',
|
|
22
|
-
Q.where('collection_id',
|
|
21
|
+
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
22
|
+
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
23
23
|
|
|
24
24
|
Q.where('state', STATE.COMPLETED),
|
|
25
25
|
Q.sortBy('updated_at', 'desc'),
|
|
@@ -55,8 +55,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
55
55
|
} = {}
|
|
56
56
|
) {
|
|
57
57
|
const clauses: Q.Clause[] = [
|
|
58
|
-
Q.where('collection_type',
|
|
59
|
-
Q.where('collection_id',
|
|
58
|
+
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
59
|
+
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
60
60
|
|
|
61
61
|
Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
|
|
62
62
|
Q.sortBy('updated_at', 'desc'),
|
|
@@ -80,8 +80,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
80
80
|
async mostRecentlyUpdatedId(contentIds: number[], collection: { type: COLLECTION_TYPE; id: number } | null = null) {
|
|
81
81
|
return this.queryOneId(
|
|
82
82
|
Q.where('content_id', Q.oneOf(contentIds)),
|
|
83
|
-
Q.where('collection_type', collection?.type ??
|
|
84
|
-
Q.where('collection_id', collection?.id ??
|
|
83
|
+
Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
|
|
84
|
+
Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
|
|
85
85
|
|
|
86
86
|
Q.sortBy('updated_at', 'desc')
|
|
87
87
|
)
|
|
@@ -93,8 +93,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
93
93
|
) {
|
|
94
94
|
const clauses = [
|
|
95
95
|
Q.where('content_id', contentId),
|
|
96
|
-
Q.where('collection_type', collection?.type ??
|
|
97
|
-
Q.where('collection_id', collection?.id ??
|
|
96
|
+
Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
|
|
97
|
+
Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
|
|
98
98
|
]
|
|
99
99
|
|
|
100
100
|
return await this.queryOne(...clauses)
|
|
@@ -106,8 +106,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
106
106
|
) {
|
|
107
107
|
const clauses = [
|
|
108
108
|
Q.where('content_id', Q.oneOf(contentIds)),
|
|
109
|
-
Q.where('collection_type', collection?.type ??
|
|
110
|
-
Q.where('collection_id', collection?.id ??
|
|
109
|
+
Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
|
|
110
|
+
Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
|
|
111
111
|
]
|
|
112
112
|
|
|
113
113
|
return await this.queryAll(...clauses)
|
|
@@ -118,8 +118,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
118
118
|
|
|
119
119
|
const result = this.upsertOne(id, (r) => {
|
|
120
120
|
r.content_id = contentId
|
|
121
|
-
r.collection_type = collection?.type ??
|
|
122
|
-
r.collection_id = collection?.id ??
|
|
121
|
+
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
122
|
+
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
123
123
|
|
|
124
124
|
r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
|
|
125
125
|
r.progress_percent = progressPct
|
|
@@ -142,8 +142,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
142
142
|
progressPercent: progressPct,
|
|
143
143
|
progressStatus: progressPct === 100 ? STATE.COMPLETED : STATE.STARTED,
|
|
144
144
|
bubble: true,
|
|
145
|
-
collectionType: collection?.type ??
|
|
146
|
-
collectionId: collection?.id ??
|
|
145
|
+
collectionType: collection?.type ?? COLLECTION_TYPE.SELF,
|
|
146
|
+
collectionId: collection?.id ?? COLLECTION_ID_SELF,
|
|
147
147
|
resumeTimeSeconds: resumeTime ?? null,
|
|
148
148
|
timestamp: Date.now()
|
|
149
149
|
})
|
|
@@ -165,8 +165,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
165
165
|
ProgressRepository.generateId(contentId, collection),
|
|
166
166
|
(r: ContentProgress) => {
|
|
167
167
|
r.content_id = contentId
|
|
168
|
-
r.collection_type = collection?.type ??
|
|
169
|
-
r.collection_id = collection?.id ??
|
|
168
|
+
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
169
|
+
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
170
170
|
|
|
171
171
|
r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
|
|
172
172
|
r.progress_percent = progressPct
|
|
@@ -185,8 +185,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
185
185
|
ProgressRepository.generateId(+contentId, collection),
|
|
186
186
|
(r: ContentProgress) => {
|
|
187
187
|
r.content_id = +contentId
|
|
188
|
-
r.collection_type = collection?.type ??
|
|
189
|
-
r.collection_id = collection?.id ??
|
|
188
|
+
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
189
|
+
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
190
190
|
|
|
191
191
|
r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
|
|
192
192
|
r.progress_percent = progressPct
|
|
@@ -204,10 +204,6 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
204
204
|
contentId: number,
|
|
205
205
|
collection: { type: COLLECTION_TYPE; id: number } | null
|
|
206
206
|
) {
|
|
207
|
-
|
|
208
|
-
return `${contentId}:${collection.type}:${collection.id}`
|
|
209
|
-
} else {
|
|
210
|
-
return `${contentId}`
|
|
211
|
-
}
|
|
207
|
+
return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
|
|
212
208
|
}
|
|
213
209
|
}
|
|
@@ -270,7 +270,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
270
270
|
if (recreate) {
|
|
271
271
|
return this.collection.prepareCreate(record => {
|
|
272
272
|
record._raw.id = id
|
|
273
|
-
record._raw.created_at = recreate.created_at
|
|
273
|
+
record._raw.created_at = recreate.created_at as EpochMs
|
|
274
274
|
record._raw.updated_at = this.generateTimestamp()
|
|
275
275
|
record._raw._status = 'created'
|
|
276
276
|
builder(record)
|
package/test/initializeTests.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { globalConfig, initializeService } from '../src'
|
|
2
2
|
import { LocalStorageMock } from './localStorageMock'
|
|
3
|
+
import { initializeSyncManager } from './sync/initialize-sync-manager'
|
|
3
4
|
const railContentModule = require('../src/services/railcontent.js')
|
|
4
5
|
let token = null
|
|
5
6
|
let userId = process.env.RAILCONTENT_USER_ID ?? null
|
|
@@ -43,7 +44,7 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
|
|
|
43
44
|
baseUrl: process.env.RAILCONTENT_BASE_URL || 'https://test.musora.com',
|
|
44
45
|
token: token,
|
|
45
46
|
userId: userId,
|
|
46
|
-
authToken: token
|
|
47
|
+
authToken: token,
|
|
47
48
|
},
|
|
48
49
|
sessionConfig: { token: token, userId: userId, authToken: token },
|
|
49
50
|
baseUrl: process.env.RAILCONTENT_BASE_URL,
|
|
@@ -51,9 +52,10 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
|
|
|
51
52
|
isMA: true,
|
|
52
53
|
}
|
|
53
54
|
initializeService(config)
|
|
54
|
-
|
|
55
55
|
// Mock user permissions
|
|
56
56
|
let permissionsMock = jest.spyOn(railContentModule, 'fetchUserPermissionsData')
|
|
57
57
|
let permissionsData = { permissions: [108, 91, 92], isAdmin: isAdmin }
|
|
58
58
|
permissionsMock.mockImplementation(() => permissionsData)
|
|
59
|
+
|
|
60
|
+
initializeSyncManager(userId)
|
|
59
61
|
}
|
|
@@ -1,21 +1,70 @@
|
|
|
1
1
|
import { initializeTestService } from './initializeTests.js'
|
|
2
2
|
import {
|
|
3
3
|
fetchLearningPathLessons,
|
|
4
|
-
|
|
4
|
+
getEnrichedLearningPath,
|
|
5
|
+
startLearningPath,
|
|
6
|
+
resetAllLearningPaths,
|
|
7
|
+
getActivePath,
|
|
5
8
|
} from '../src/services/content-org/learning-paths.ts'
|
|
6
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
contentStatusCompleted,
|
|
11
|
+
contentStatusReset,
|
|
12
|
+
getProgressDataByIds,
|
|
13
|
+
} from '../src/services/contentProgress.js'
|
|
7
14
|
describe('learning-paths', function () {
|
|
8
15
|
beforeEach(async () => {
|
|
9
16
|
await initializeTestService(true)
|
|
10
17
|
})
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
test('getlearningPathLessonsTestNew', async () => {
|
|
16
|
-
await contentStatusCompleted(417105)
|
|
17
|
-
const userDate = new Date('2025-10-31')
|
|
18
|
-
const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
|
|
19
|
-
console.log(results)
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
// Flush all pending promises
|
|
21
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
20
22
|
})
|
|
23
|
+
|
|
24
|
+
// test('getLearningPathsV2Test', async () => {
|
|
25
|
+
// const results = await getEnrichedLearningPath(417140)
|
|
26
|
+
// })
|
|
27
|
+
// test('getlearningPathLessonsTestNew', async () => {
|
|
28
|
+
// await contentStatusCompleted(417105)
|
|
29
|
+
// const userDate = new Date('2025-10-31')
|
|
30
|
+
// const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
|
|
31
|
+
// console.log(results)
|
|
32
|
+
// })
|
|
33
|
+
// test('getlearningPathLessonsTestNew', async () => {
|
|
34
|
+
// await contentStatusCompleted(417105)
|
|
35
|
+
// const userDate = new Date('2025-10-31')
|
|
36
|
+
// const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
|
|
37
|
+
// console.log(results)
|
|
38
|
+
// })
|
|
39
|
+
|
|
40
|
+
test('learningPathCompletion', async () => {
|
|
41
|
+
const learningPathId = 435527
|
|
42
|
+
await contentStatusReset(learningPathId)
|
|
43
|
+
await resetAllLearningPaths()
|
|
44
|
+
await startLearningPath('drumeo', learningPathId)
|
|
45
|
+
const collection = { type: 'learning-path-v2', id: learningPathId }
|
|
46
|
+
const learningPath = await getEnrichedLearningPath(learningPathId)
|
|
47
|
+
|
|
48
|
+
// Complete each child one by one
|
|
49
|
+
for (const child of learningPath.children) {
|
|
50
|
+
await contentStatusReset(child.id)
|
|
51
|
+
await contentStatusCompleted(child.id, collection)
|
|
52
|
+
|
|
53
|
+
// Check child status
|
|
54
|
+
const childProgress = await getProgressDataByIds([child.id], collection)
|
|
55
|
+
|
|
56
|
+
// Check parent status after each child
|
|
57
|
+
const parentProgress = await getProgressDataByIds([learningPathId], collection)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Final check - parent should be completed
|
|
61
|
+
const finalParentProgress = await getProgressDataByIds([learningPathId], collection)
|
|
62
|
+
console.log('\n--- Final parent progress:', finalParentProgress)
|
|
63
|
+
expect(finalParentProgress[learningPathId]?.status).toBe('completed')
|
|
64
|
+
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 5000))
|
|
66
|
+
|
|
67
|
+
const activePath = await getActivePath('drumeo')
|
|
68
|
+
expect(activePath.active_learning_path_id).toBe(435563)
|
|
69
|
+
}, 15000)
|
|
21
70
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import adapterFactory from '../../src/services/sync/adapters/factory'
|
|
2
|
+
import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
|
|
3
|
+
import EventEmitter from '../../src/services/sync/utils/event-emitter'
|
|
4
|
+
|
|
5
|
+
export default function syncStoreAdapter(userId: string, bus: SyncAdapterEventBus) {
|
|
6
|
+
return adapterFactory(LokiJSAdapter, `user:${userId}`, {
|
|
7
|
+
useWebWorker: false,
|
|
8
|
+
useIncrementalIndexedDB: true,
|
|
9
|
+
extraLokiOptions: {
|
|
10
|
+
autosave: true,
|
|
11
|
+
autosaveInterval: 300, // increase for better performance at cost of potential data loss on tab close/crash
|
|
12
|
+
},
|
|
13
|
+
onQuotaExceededError: () => {
|
|
14
|
+
// Browser ran out of disk space or possibly in incognito mode
|
|
15
|
+
// called ONLY at startup
|
|
16
|
+
// ideal place to trigger banner (?) to user when also offline?
|
|
17
|
+
// (so that the non-customizable browser default onbeforeunload confirmation (in offline-unload-warning.ts) has context and makes sense)
|
|
18
|
+
bus.emit('quotaExceededError')
|
|
19
|
+
},
|
|
20
|
+
onSetUpError: () => {
|
|
21
|
+
// TODO - Database failed to load -- offer the user to reload the app or log out
|
|
22
|
+
},
|
|
23
|
+
extraIncrementalIDBOptions: {
|
|
24
|
+
lazyCollections: ['content_like'],
|
|
25
|
+
onDidOverwrite: () => {
|
|
26
|
+
// Called when this adapter is forced to overwrite contents of IndexedDB.
|
|
27
|
+
// This happens if there's another open tab of the same app that's making changes.
|
|
28
|
+
// this scenario is handled-ish in `idb-clobber-avoidance`
|
|
29
|
+
},
|
|
30
|
+
onversionchange: () => {
|
|
31
|
+
// no-op
|
|
32
|
+
// indexeddb was deleted in another browser tab (user logged out), so we must make sure we delete
|
|
33
|
+
// in-memory db in this tab as well,
|
|
34
|
+
// but we rely on sync manager setup/teardown to `unsafeResetDatabase` and redirect for this,
|
|
35
|
+
// though reloading the page might be useful as well
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class SyncAdapterEventBus extends EventEmitter<{ quotaExceededError: [] }> {}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { SyncManager, SyncContext } from '../../src/services/sync/index'
|
|
2
|
+
import {
|
|
3
|
+
BaseSessionProvider,
|
|
4
|
+
BaseConnectivityProvider,
|
|
5
|
+
BaseDurabilityProvider,
|
|
6
|
+
BaseTabsProvider,
|
|
7
|
+
BaseVisibilityProvider,
|
|
8
|
+
} from '../../src/services/sync/context/providers/'
|
|
9
|
+
import adapterFactory from '../../src/services/sync/adapters/factory'
|
|
10
|
+
import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
|
|
11
|
+
import EventEmitter from '../../src/services/sync/utils/event-emitter'
|
|
12
|
+
import { InitialStrategy, PollingStrategy } from '../../src/services/sync/strategies/index'
|
|
13
|
+
import { SyncTelemetry, SentryLike } from '../../src/services/sync/telemetry/index'
|
|
14
|
+
import {
|
|
15
|
+
ContentLike,
|
|
16
|
+
ContentProgress,
|
|
17
|
+
Practice,
|
|
18
|
+
PracticeDayNote,
|
|
19
|
+
} from '../../src/services/sync/models/index'
|
|
20
|
+
import syncDatabaseFactory from '../../src/services/sync/database/factory'
|
|
21
|
+
|
|
22
|
+
import syncAdapter, { SyncAdapterEventBus } from './adapter'
|
|
23
|
+
|
|
24
|
+
export function initializeSyncManager(userId) {
|
|
25
|
+
if (SyncManager.getInstanceOrNull()) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
const dummySentry = {
|
|
29
|
+
captureException: () => '',
|
|
30
|
+
addBreadcrumb: () => {},
|
|
31
|
+
startSpan: (options, callback) => {
|
|
32
|
+
// Return a mock span object with the properties Sentry expects
|
|
33
|
+
const mockSpan = {
|
|
34
|
+
_spanId: 'mock-span-id',
|
|
35
|
+
end: () => {},
|
|
36
|
+
setStatus: () => {},
|
|
37
|
+
setData: () => {},
|
|
38
|
+
setAttribute: () => {},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (callback) {
|
|
42
|
+
return callback(mockSpan)
|
|
43
|
+
}
|
|
44
|
+
return mockSpan
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
SyncTelemetry.setInstance(new SyncTelemetry(userId, { Sentry: dummySentry }))
|
|
49
|
+
|
|
50
|
+
const adapterBus = new SyncAdapterEventBus()
|
|
51
|
+
const adapter = syncAdapter(userId, adapterBus)
|
|
52
|
+
const db = syncDatabaseFactory(adapter)
|
|
53
|
+
|
|
54
|
+
const context = new SyncContext({
|
|
55
|
+
session: {
|
|
56
|
+
getClientId: () => 'test-client-id',
|
|
57
|
+
getSessionId: () => null,
|
|
58
|
+
start: () => {},
|
|
59
|
+
stop: () => {},
|
|
60
|
+
},
|
|
61
|
+
connectivity: {
|
|
62
|
+
getValue: () => true,
|
|
63
|
+
subscribe: () => () => {},
|
|
64
|
+
notifyListeners: () => {},
|
|
65
|
+
start: () => {},
|
|
66
|
+
stop: () => {},
|
|
67
|
+
},
|
|
68
|
+
visibility: {
|
|
69
|
+
getValue: () => true,
|
|
70
|
+
subscribe: () => () => {},
|
|
71
|
+
notifyListeners: () => {},
|
|
72
|
+
start: () => {},
|
|
73
|
+
stop: () => {},
|
|
74
|
+
},
|
|
75
|
+
tabs: {
|
|
76
|
+
hasOtherTabs: () => false,
|
|
77
|
+
broadcast: () => {},
|
|
78
|
+
subscribe: () => () => {},
|
|
79
|
+
start: () => {},
|
|
80
|
+
stop: () => {},
|
|
81
|
+
},
|
|
82
|
+
durability: {
|
|
83
|
+
getValue: () => true,
|
|
84
|
+
start: () => {},
|
|
85
|
+
stop: () => {},
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
const manager = new SyncManager(context, db)
|
|
89
|
+
|
|
90
|
+
const initialStrategy = manager.createStrategy(InitialStrategy)
|
|
91
|
+
const aggressivePollingStrategy = manager.createStrategy(PollingStrategy, 3600_000)
|
|
92
|
+
|
|
93
|
+
manager.syncStoresWithStrategies(
|
|
94
|
+
manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
|
|
95
|
+
[initialStrategy, aggressivePollingStrategy]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
manager.protectStores(
|
|
99
|
+
manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
|
|
100
|
+
[]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
SyncManager.assignAndSetupInstance(manager)
|
|
104
|
+
}
|