adapt-authoring-contentplugin 0.0.1 → 1.0.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/.eslintignore +1 -1
- package/.eslintrc +14 -14
- package/.github/ISSUE_TEMPLATE/bug_report.yml +55 -55
- package/.github/ISSUE_TEMPLATE/feature_request.yml +22 -22
- package/.github/dependabot.yml +11 -11
- package/.github/pull_request_template.md +25 -25
- package/.github/workflows/labelled_prs.yml +16 -16
- package/.github/workflows/new.yml +19 -19
- package/.github/workflows/releases.yml +25 -0
- package/adapt-authoring.json +5 -5
- package/conf/config.schema.json +12 -12
- package/errors/errors.json +76 -76
- package/index.js +5 -5
- package/lib/ContentPluginModule.js +440 -438
- package/lib/apidefs.js +67 -67
- package/package.json +52 -17
- package/schema/contentplugin.schema.json +58 -58
|
@@ -1,438 +1,440 @@
|
|
|
1
|
-
import AbstractApiModule from 'adapt-authoring-api'
|
|
2
|
-
import apidefs from './apidefs.js'
|
|
3
|
-
import fs from 'fs/promises'
|
|
4
|
-
import { glob } from 'glob'
|
|
5
|
-
import path from 'path'
|
|
6
|
-
import semver from 'semver'
|
|
7
|
-
/**
|
|
8
|
-
* Abstract module which handles framework plugins
|
|
9
|
-
* @memberof contentplugin
|
|
10
|
-
* @extends {AbstractApiModule}
|
|
11
|
-
*/
|
|
12
|
-
class ContentPluginModule extends AbstractApiModule {
|
|
13
|
-
/** @override */
|
|
14
|
-
async setValues () {
|
|
15
|
-
/** @ignore */ this.collectionName = 'contentplugins'
|
|
16
|
-
/** @ignore */ this.root = 'contentplugins'
|
|
17
|
-
/** @ignore */ this.schemaName = 'contentplugin'
|
|
18
|
-
/**
|
|
19
|
-
* Reference to all content plugin schemas, grouped by plugin
|
|
20
|
-
* @type {Object}
|
|
21
|
-
*/
|
|
22
|
-
this.pluginSchemas = {}
|
|
23
|
-
/**
|
|
24
|
-
* A list of newly installed plugins
|
|
25
|
-
* @type {Array}
|
|
26
|
-
*/
|
|
27
|
-
this.newPlugins = []
|
|
28
|
-
|
|
29
|
-
const middleware = await this.app.waitForModule('middleware')
|
|
30
|
-
|
|
31
|
-
this.useDefaultRouteConfig()
|
|
32
|
-
// remove unnecessary routes
|
|
33
|
-
delete this.routes.find(r => r.route === '/').handlers.post
|
|
34
|
-
delete this.routes.find(r => r.route === '/:_id').handlers.put
|
|
35
|
-
// extra routes
|
|
36
|
-
this.routes.push({
|
|
37
|
-
route: '/install',
|
|
38
|
-
handlers: {
|
|
39
|
-
post: [
|
|
40
|
-
middleware.fileUploadParser(middleware.zipTypes, { unzip: true }),
|
|
41
|
-
this.installHandler.bind(this)
|
|
42
|
-
]
|
|
43
|
-
},
|
|
44
|
-
permissions: { post: ['install:contentplugin'] },
|
|
45
|
-
validate: false,
|
|
46
|
-
meta: apidefs.install
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
route: '/:_id/update',
|
|
50
|
-
handlers: { post: this.updateHandler.bind(this) },
|
|
51
|
-
permissions: { post: ['update:contentplugin'] },
|
|
52
|
-
meta: apidefs.update
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
route: '/:_id/uses',
|
|
56
|
-
handlers: { get: this.usesHandler.bind(this) },
|
|
57
|
-
permissions: { get: ['read:contentplugin'] },
|
|
58
|
-
meta: apidefs.uses
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** @override */
|
|
63
|
-
async init () {
|
|
64
|
-
await super.init()
|
|
65
|
-
// env var used by the CLI
|
|
66
|
-
if (!process.env.ADAPT_ALLOW_PRERELEASE) {
|
|
67
|
-
process.env.ADAPT_ALLOW_PRERELEASE = 'true'
|
|
68
|
-
}
|
|
69
|
-
const [framework, mongodb] = await this.app.waitForModule('adaptframework', 'mongodb')
|
|
70
|
-
|
|
71
|
-
await mongodb.setIndex(this.collectionName, 'name', { unique: true })
|
|
72
|
-
await mongodb.setIndex(this.collectionName, 'displayName', { unique: true })
|
|
73
|
-
await mongodb.setIndex(this.collectionName, 'type')
|
|
74
|
-
/**
|
|
75
|
-
* Cached module instance for easy access
|
|
76
|
-
* @type {AdaptFrameworkModule}
|
|
77
|
-
*/
|
|
78
|
-
this.framework = framework
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
await this.initPlugins()
|
|
82
|
-
} catch (e) {
|
|
83
|
-
this.log('error', e)
|
|
84
|
-
}
|
|
85
|
-
this.framework.postInstallHook.tap(this.syncPluginData.bind(this))
|
|
86
|
-
this.framework.postUpdateHook.tap(this.syncPluginData.bind(this))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** @override */
|
|
90
|
-
async find (query = {}, options = {}, mongoOptions = {}) {
|
|
91
|
-
const includeUpdateInfo = options.includeUpdateInfo === true || options.includeUpdateInfo === 'true'
|
|
92
|
-
// special option that's passed via query
|
|
93
|
-
delete query.includeUpdateInfo
|
|
94
|
-
const results = await super.find(query, options, mongoOptions)
|
|
95
|
-
if (includeUpdateInfo) {
|
|
96
|
-
const updateInfo = await this.framework.runCliCommand('getPluginUpdateInfos', { plugins: results.map(r => r.name) })
|
|
97
|
-
results.forEach(r => {
|
|
98
|
-
const info = updateInfo.find(i => i.name === r.name)
|
|
99
|
-
if (info) {
|
|
100
|
-
r.canBeUpdated = info.canBeUpdated
|
|
101
|
-
r.latestCompatibleVersion = info.latestCompatibleSourceVersion
|
|
102
|
-
r._cliData = info
|
|
103
|
-
}
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
return results
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async readJson(filepath) {
|
|
110
|
-
return JSON.parse(await fs.readFile(filepath))
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Inserts a new document or performs an update if matching data already exists
|
|
115
|
-
* @param {Object} data Data to be sent to the DB
|
|
116
|
-
* @param {Object} options Options to pass to the DB function
|
|
117
|
-
* @returns {Promise} Resolves with the returned data
|
|
118
|
-
*/
|
|
119
|
-
async insertOrUpdate (data, options = { useDefaults: true }) {
|
|
120
|
-
return !(await this.find({ name: data.name })).length
|
|
121
|
-
? this.insert(data, options)
|
|
122
|
-
: this.update({ name: data.name }, data, options)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** @override */
|
|
126
|
-
async delete (query, options, mongoOptions) {
|
|
127
|
-
const _id = query._id
|
|
128
|
-
const courses = await this.getPluginUses(_id)
|
|
129
|
-
if (courses.length) {
|
|
130
|
-
throw this.app.errors.CONTENTPLUGIN_IN_USE.setData({ courses })
|
|
131
|
-
}
|
|
132
|
-
const [pluginData] = await this.find({ _id })
|
|
133
|
-
// unregister any schemas
|
|
134
|
-
const jsonschema = await this.app.waitForModule('jsonschema')
|
|
135
|
-
const schemaPaths = await glob(`src/*/${pluginData.name}/schema/*.schema.json`, { cwd: this.framework.path, absolute: true })
|
|
136
|
-
schemaPaths.forEach(s => jsonschema.deregisterSchema(s))
|
|
137
|
-
|
|
138
|
-
await this.framework.runCliCommand('uninstallPlugins', { plugins: [pluginData.name] })
|
|
139
|
-
this.log('info', `successfully removed plugin ${pluginData.name}`)
|
|
140
|
-
return super.delete(query, options, mongoOptions)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Initialises all framework plugins, from adapt.json and local cache
|
|
145
|
-
* @return {Promise}
|
|
146
|
-
*/
|
|
147
|
-
async initPlugins () {
|
|
148
|
-
await this.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
this.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
*
|
|
173
|
-
* @return {Array} List of plugin names mapped to version/dir
|
|
174
|
-
*/
|
|
175
|
-
async getMissingPlugins() {
|
|
176
|
-
const dbPlugins = await this.find()
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
this.pluginSchemas[name].
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
*
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
*
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
*
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
{ $
|
|
246
|
-
{ $
|
|
247
|
-
{ $
|
|
248
|
-
{ $
|
|
249
|
-
{ $
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
*
|
|
257
|
-
* @param {
|
|
258
|
-
* @param {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.
|
|
268
|
-
|
|
269
|
-
this.log('
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
*
|
|
284
|
-
* @param {
|
|
285
|
-
* @param {
|
|
286
|
-
* @param {
|
|
287
|
-
* @
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const [
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
*
|
|
322
|
-
* @
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
pkg = await this.readJson(path.join(sourcePath, '
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
*
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
await this.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
*
|
|
385
|
-
* @param {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
*
|
|
411
|
-
* @param {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
*
|
|
426
|
-
* @param {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
1
|
+
import AbstractApiModule from 'adapt-authoring-api'
|
|
2
|
+
import apidefs from './apidefs.js'
|
|
3
|
+
import fs from 'fs/promises'
|
|
4
|
+
import { glob } from 'glob'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import semver from 'semver'
|
|
7
|
+
/**
|
|
8
|
+
* Abstract module which handles framework plugins
|
|
9
|
+
* @memberof contentplugin
|
|
10
|
+
* @extends {AbstractApiModule}
|
|
11
|
+
*/
|
|
12
|
+
class ContentPluginModule extends AbstractApiModule {
|
|
13
|
+
/** @override */
|
|
14
|
+
async setValues () {
|
|
15
|
+
/** @ignore */ this.collectionName = 'contentplugins'
|
|
16
|
+
/** @ignore */ this.root = 'contentplugins'
|
|
17
|
+
/** @ignore */ this.schemaName = 'contentplugin'
|
|
18
|
+
/**
|
|
19
|
+
* Reference to all content plugin schemas, grouped by plugin
|
|
20
|
+
* @type {Object}
|
|
21
|
+
*/
|
|
22
|
+
this.pluginSchemas = {}
|
|
23
|
+
/**
|
|
24
|
+
* A list of newly installed plugins
|
|
25
|
+
* @type {Array}
|
|
26
|
+
*/
|
|
27
|
+
this.newPlugins = []
|
|
28
|
+
|
|
29
|
+
const middleware = await this.app.waitForModule('middleware')
|
|
30
|
+
|
|
31
|
+
this.useDefaultRouteConfig()
|
|
32
|
+
// remove unnecessary routes
|
|
33
|
+
delete this.routes.find(r => r.route === '/').handlers.post
|
|
34
|
+
delete this.routes.find(r => r.route === '/:_id').handlers.put
|
|
35
|
+
// extra routes
|
|
36
|
+
this.routes.push({
|
|
37
|
+
route: '/install',
|
|
38
|
+
handlers: {
|
|
39
|
+
post: [
|
|
40
|
+
middleware.fileUploadParser(middleware.zipTypes, { unzip: true }),
|
|
41
|
+
this.installHandler.bind(this)
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
permissions: { post: ['install:contentplugin'] },
|
|
45
|
+
validate: false,
|
|
46
|
+
meta: apidefs.install
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
route: '/:_id/update',
|
|
50
|
+
handlers: { post: this.updateHandler.bind(this) },
|
|
51
|
+
permissions: { post: ['update:contentplugin'] },
|
|
52
|
+
meta: apidefs.update
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
route: '/:_id/uses',
|
|
56
|
+
handlers: { get: this.usesHandler.bind(this) },
|
|
57
|
+
permissions: { get: ['read:contentplugin'] },
|
|
58
|
+
meta: apidefs.uses
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @override */
|
|
63
|
+
async init () {
|
|
64
|
+
await super.init()
|
|
65
|
+
// env var used by the CLI
|
|
66
|
+
if (!process.env.ADAPT_ALLOW_PRERELEASE) {
|
|
67
|
+
process.env.ADAPT_ALLOW_PRERELEASE = 'true'
|
|
68
|
+
}
|
|
69
|
+
const [framework, mongodb] = await this.app.waitForModule('adaptframework', 'mongodb')
|
|
70
|
+
|
|
71
|
+
await mongodb.setIndex(this.collectionName, 'name', { unique: true })
|
|
72
|
+
await mongodb.setIndex(this.collectionName, 'displayName', { unique: true })
|
|
73
|
+
await mongodb.setIndex(this.collectionName, 'type')
|
|
74
|
+
/**
|
|
75
|
+
* Cached module instance for easy access
|
|
76
|
+
* @type {AdaptFrameworkModule}
|
|
77
|
+
*/
|
|
78
|
+
this.framework = framework
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await this.initPlugins()
|
|
82
|
+
} catch (e) {
|
|
83
|
+
this.log('error', e)
|
|
84
|
+
}
|
|
85
|
+
this.framework.postInstallHook.tap(this.syncPluginData.bind(this))
|
|
86
|
+
this.framework.postUpdateHook.tap(this.syncPluginData.bind(this))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @override */
|
|
90
|
+
async find (query = {}, options = {}, mongoOptions = {}) {
|
|
91
|
+
const includeUpdateInfo = options.includeUpdateInfo === true || options.includeUpdateInfo === 'true'
|
|
92
|
+
// special option that's passed via query
|
|
93
|
+
delete query.includeUpdateInfo
|
|
94
|
+
const results = await super.find(query, options, mongoOptions)
|
|
95
|
+
if (includeUpdateInfo) {
|
|
96
|
+
const updateInfo = await this.framework.runCliCommand('getPluginUpdateInfos', { plugins: results.map(r => r.name) })
|
|
97
|
+
results.forEach(r => {
|
|
98
|
+
const info = updateInfo.find(i => i.name === r.name)
|
|
99
|
+
if (info) {
|
|
100
|
+
r.canBeUpdated = info.canBeUpdated
|
|
101
|
+
r.latestCompatibleVersion = info.latestCompatibleSourceVersion
|
|
102
|
+
r._cliData = info
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return results
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async readJson(filepath) {
|
|
110
|
+
return JSON.parse(await fs.readFile(filepath))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Inserts a new document or performs an update if matching data already exists
|
|
115
|
+
* @param {Object} data Data to be sent to the DB
|
|
116
|
+
* @param {Object} options Options to pass to the DB function
|
|
117
|
+
* @returns {Promise} Resolves with the returned data
|
|
118
|
+
*/
|
|
119
|
+
async insertOrUpdate (data, options = { useDefaults: true }) {
|
|
120
|
+
return !(await this.find({ name: data.name })).length
|
|
121
|
+
? this.insert(data, options)
|
|
122
|
+
: this.update({ name: data.name }, data, options)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @override */
|
|
126
|
+
async delete (query, options, mongoOptions) {
|
|
127
|
+
const _id = query._id
|
|
128
|
+
const courses = await this.getPluginUses(_id)
|
|
129
|
+
if (courses.length) {
|
|
130
|
+
throw this.app.errors.CONTENTPLUGIN_IN_USE.setData({ courses })
|
|
131
|
+
}
|
|
132
|
+
const [pluginData] = await this.find({ _id })
|
|
133
|
+
// unregister any schemas
|
|
134
|
+
const jsonschema = await this.app.waitForModule('jsonschema')
|
|
135
|
+
const schemaPaths = await glob(`src/*/${pluginData.name}/schema/*.schema.json`, { cwd: this.framework.path, absolute: true })
|
|
136
|
+
schemaPaths.forEach(s => jsonschema.deregisterSchema(s))
|
|
137
|
+
|
|
138
|
+
await this.framework.runCliCommand('uninstallPlugins', { plugins: [pluginData.name] })
|
|
139
|
+
this.log('info', `successfully removed plugin ${pluginData.name}`)
|
|
140
|
+
return super.delete(query, options, mongoOptions)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Initialises all framework plugins, from adapt.json and local cache
|
|
145
|
+
* @return {Promise}
|
|
146
|
+
*/
|
|
147
|
+
async initPlugins () {
|
|
148
|
+
const missing = await this.getMissingPlugins()
|
|
149
|
+
if (missing.length) { // note we're using CLI directly, as plugins already exist in the DB
|
|
150
|
+
this.log('debug', 'MISSING', missing)
|
|
151
|
+
await this.framework.runCliCommand('installPlugins', { plugins: missing })
|
|
152
|
+
}
|
|
153
|
+
await this.syncPluginData()
|
|
154
|
+
await this.processPluginSchemas()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Makes sure the database plugin data is in sync with the currently installed framework plugins
|
|
159
|
+
*/
|
|
160
|
+
async syncPluginData() {
|
|
161
|
+
const dbInfo = (await this.find()).reduce((memo, info) => Object.assign(memo, { [info.name]: info }), {})
|
|
162
|
+
for (const i of await this.framework.runCliCommand('getPluginUpdateInfos')) {
|
|
163
|
+
if(dbInfo[i.name]?.version !== i.matchedVersion) {
|
|
164
|
+
this.log('debug', 'SYNC', i.name, 'local:', dbInfo[i.name]?.version, 'fw:', i.matchedVersion)
|
|
165
|
+
await this.insertOrUpdate({ ...(await i.getInfo()), type: await i.getType(), isLocalInstall: i.isLocalSource })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Returns a list of plugins defined in the database but not installed in the framework
|
|
172
|
+
* If no plugins are defined in the database, it returns all plugins defined in the framework manifest
|
|
173
|
+
* @return {Array} List of plugin names mapped to version/dir
|
|
174
|
+
*/
|
|
175
|
+
async getMissingPlugins() {
|
|
176
|
+
const dbPlugins = await this.find()
|
|
177
|
+
if (!dbPlugins.length)
|
|
178
|
+
return (await this.framework.getManifestPlugins()).map(([name, version]) => `${name}@${version}`)
|
|
179
|
+
const fwPlugins = await this.framework.getInstalledPlugins()
|
|
180
|
+
return dbPlugins
|
|
181
|
+
.filter(dbP => !fwPlugins.find(fwP => dbP.name === fwP.name))
|
|
182
|
+
.map(p => `${p.name}@${p.isLocalInstall ? path.join(this.getConfig('pluginDir'), p.name) : p.version}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Loads and processes all installed content plugin schemas
|
|
187
|
+
* @param {Array} pluginInfo Plugin info data
|
|
188
|
+
* @return {Promise}
|
|
189
|
+
*/
|
|
190
|
+
async processPluginSchemas (pluginInfo) {
|
|
191
|
+
if (!pluginInfo) {
|
|
192
|
+
pluginInfo = await this.framework.runCliCommand('getPluginUpdateInfos')
|
|
193
|
+
}
|
|
194
|
+
const jsonschema = await this.app.waitForModule('jsonschema')
|
|
195
|
+
return Promise.all(pluginInfo.map(async plugin => {
|
|
196
|
+
const name = plugin.name
|
|
197
|
+
const oldSchemaPaths = this.pluginSchemas[name]
|
|
198
|
+
if (oldSchemaPaths) {
|
|
199
|
+
Object.values(oldSchemaPaths).forEach(s => jsonschema.deregisterSchema(s))
|
|
200
|
+
delete this.pluginSchemas[name]
|
|
201
|
+
}
|
|
202
|
+
const schemaPaths = await plugin.getSchemaPaths()
|
|
203
|
+
return Promise.all(schemaPaths.map(async schemaPath => {
|
|
204
|
+
const schema = await this.readJson(schemaPath)
|
|
205
|
+
const source = schema?.$patch?.source?.$ref
|
|
206
|
+
if (source) {
|
|
207
|
+
if (!this.pluginSchemas[name]) this.pluginSchemas[name] = []
|
|
208
|
+
if (this.pluginSchemas[name].includes(schema.$anchor)) jsonschema.deregisterSchema(this.pluginSchemas[name][source])
|
|
209
|
+
this.pluginSchemas[name].push(schema.$anchor)
|
|
210
|
+
}
|
|
211
|
+
return jsonschema.registerSchema(schemaPath, { replace: true })
|
|
212
|
+
}))
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Returns whether a schema is registered by a plugin
|
|
218
|
+
* @param {String} schemaName Name of the schema to check
|
|
219
|
+
* @return {Boolean}
|
|
220
|
+
*/
|
|
221
|
+
isPluginSchema (schemaName) {
|
|
222
|
+
for (const p in this.pluginSchemas) {
|
|
223
|
+
if (this.pluginSchemas[p].includes(schemaName)) return true
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns all schemas registered by a plugin
|
|
229
|
+
* @param {String} pluginName Plugin name
|
|
230
|
+
* @return {Array} List of the plugin's registered schemas
|
|
231
|
+
*/
|
|
232
|
+
getPluginSchemas (pluginName) {
|
|
233
|
+
return this.pluginSchemas[pluginName] ?? []
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Retrieves the courses in which a plugin is used
|
|
238
|
+
* @param {String} pluginId Plugin _id
|
|
239
|
+
* @returns {Promise} Resolves with an array of course data
|
|
240
|
+
*/
|
|
241
|
+
async getPluginUses (pluginId) {
|
|
242
|
+
const [{ name }] = await this.find({ _id: pluginId })
|
|
243
|
+
const [content, db] = await this.app.waitForModule('content', 'mongodb')
|
|
244
|
+
return (db.getCollection(content.collectionName).aggregate([
|
|
245
|
+
{ $match: { _type: 'config', _enabledPlugins: name } },
|
|
246
|
+
{ $lookup: { from: 'content', localField: '_courseId', foreignField: '_id', as: 'course' } },
|
|
247
|
+
{ $unwind: '$course' },
|
|
248
|
+
{ $replaceRoot: { newRoot: '$course' } },
|
|
249
|
+
{ $lookup: { from: 'users', localField: 'createdBy', foreignField: '_id', as: 'createdBy' } },
|
|
250
|
+
{ $project: { title: 1, createdBy: { $map: { input: '$createdBy', as: 'user', in: '$$user.email' } } } },
|
|
251
|
+
{ $unwind: '$createdBy' }
|
|
252
|
+
])).toArray()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Installs new plugins
|
|
257
|
+
* @param {Array[]} plugins 2D array of strings in the format [pluginName, versionOrPath]
|
|
258
|
+
* @param {Object} options
|
|
259
|
+
* @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
|
|
260
|
+
* @param {Boolean} options.strict Whether the function should fail on error
|
|
261
|
+
*/
|
|
262
|
+
async installPlugins (plugins, options = { strict: false, force: false }) {
|
|
263
|
+
const errors = []
|
|
264
|
+
const installed = []
|
|
265
|
+
await Promise.all(plugins.map(async ([name, versionOrPath]) => {
|
|
266
|
+
try {
|
|
267
|
+
const data = await this.installPlugin(name, versionOrPath, options)
|
|
268
|
+
installed.push(data)
|
|
269
|
+
this.log('info', 'PLUGIN_INSTALL', `${data.name}@${data.version}`)
|
|
270
|
+
} catch (e) {
|
|
271
|
+
this.log('warn', 'PLUGIN_INSTALL_FAIL', name, e?.data?.error ?? e)
|
|
272
|
+
errors.push(e)
|
|
273
|
+
}
|
|
274
|
+
}))
|
|
275
|
+
if (errors.length && options.strict) {
|
|
276
|
+
throw this.app.errors.CONTENTPLUGIN_INSTALL_FAILED
|
|
277
|
+
.setData({ errors })
|
|
278
|
+
}
|
|
279
|
+
return installed
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Installs a single plugin. Note: this function is called by installPlugins and should not be called directly.
|
|
284
|
+
* @param {String} pluginName Name of the plugin to install
|
|
285
|
+
* @param {String} versionOrPath The semver-formatted version, or the path to the plugin source
|
|
286
|
+
* @param {Object} options
|
|
287
|
+
* @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
|
|
288
|
+
* @param {Boolean} options.strict Whether the function should fail on error
|
|
289
|
+
* @returns Resolves with plugin DB data
|
|
290
|
+
*/
|
|
291
|
+
async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) {
|
|
292
|
+
const [pluginData] = await this.find({ name: String(pluginName) }, { includeUpdateInfo: true })
|
|
293
|
+
const { name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath })
|
|
294
|
+
const [ existingPlugin ] = await this.find({ name })
|
|
295
|
+
|
|
296
|
+
if (existingPlugin) {
|
|
297
|
+
if (!options.force && semver.lte(version, existingPlugin.version)) {
|
|
298
|
+
throw this.app.errors.CONTENTPLUGIN_ALREADY_EXISTS
|
|
299
|
+
.setData({ name: existingPlugin.name, version: existingPlugin.version })
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const [data] = await this.framework.runCliCommand('installPlugins', { plugins: [`${name}@${sourcePath ?? version}`] })
|
|
303
|
+
const info = await this.insertOrUpdate({
|
|
304
|
+
...(await data.getInfo()),
|
|
305
|
+
type: await data.getType(),
|
|
306
|
+
isLocalInstall
|
|
307
|
+
})
|
|
308
|
+
if (!data.isInstallSuccessful) {
|
|
309
|
+
throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED
|
|
310
|
+
.setData({ name })
|
|
311
|
+
}
|
|
312
|
+
if (!info.targetAttribute) {
|
|
313
|
+
throw this.app.errors.CONTENTPLUGIN_ATTR_MISSING
|
|
314
|
+
.setData({ name })
|
|
315
|
+
}
|
|
316
|
+
await this.processPluginSchemas([data])
|
|
317
|
+
return info
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ensures local plugin source files are stored in the correct location and structured in an expected way
|
|
322
|
+
* @param {Object} pluginData Plugin metadata
|
|
323
|
+
* @param {String} sourcePath The path to the plugin source files
|
|
324
|
+
* @returns Resolves with package data
|
|
325
|
+
*/
|
|
326
|
+
async processPluginFiles (pluginData) {
|
|
327
|
+
let sourcePath = pluginData.sourcePath
|
|
328
|
+
if (sourcePath === path.basename(sourcePath)) { // no local files
|
|
329
|
+
return { name: pluginData.name, version: sourcePath, isLocalInstall: false }
|
|
330
|
+
}
|
|
331
|
+
const contents = await fs.readdir(sourcePath)
|
|
332
|
+
if (contents.length === 1) { // deal with a nested root folder
|
|
333
|
+
sourcePath = path.join(pluginData.sourcePath, contents[0])
|
|
334
|
+
}
|
|
335
|
+
let pkg
|
|
336
|
+
try { // load package data, with fall-back to bower
|
|
337
|
+
try {
|
|
338
|
+
pkg = await this.readJson(path.join(sourcePath, 'package.json'))
|
|
339
|
+
} catch (e) {
|
|
340
|
+
pkg = await this.readJson(path.join(sourcePath, 'bower.json'))
|
|
341
|
+
}
|
|
342
|
+
pkg.sourcePath = path.join(this.getConfig('pluginDir'), pkg.name)
|
|
343
|
+
pkg.isLocalInstall = true
|
|
344
|
+
} catch (e) {
|
|
345
|
+
throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
|
|
346
|
+
}
|
|
347
|
+
// move the files into the persistent location
|
|
348
|
+
await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
|
|
349
|
+
await fs.rm(sourcePath, { recursive: true })
|
|
350
|
+
return pkg
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Updates a single plugin
|
|
355
|
+
* @param {String} _id The _id for the plugin to update
|
|
356
|
+
* @return Resolves with update data
|
|
357
|
+
*/
|
|
358
|
+
async updatePlugin (_id) {
|
|
359
|
+
const [{ name }] = await this.find({ _id })
|
|
360
|
+
const [pluginData] = await this.framework.runCliCommand('updatePlugins', { plugins: [name] })
|
|
361
|
+
const p = await this.update({ name }, pluginData._sourceInfo)
|
|
362
|
+
await this.processPluginSchemas(pluginData)
|
|
363
|
+
this.log('info', `successfully updated plugin ${p.name}@${p.version}`)
|
|
364
|
+
return p
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** @override */
|
|
368
|
+
serveSchema () {
|
|
369
|
+
return async (req, res, next) => {
|
|
370
|
+
try {
|
|
371
|
+
const plugin = await this.get({ name: req.apiData.query.type }) || {}
|
|
372
|
+
const schema = await this.getSchema(plugin.schemaName)
|
|
373
|
+
if (!schema) {
|
|
374
|
+
return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
|
|
375
|
+
}
|
|
376
|
+
res.type('application/schema+json').json(schema)
|
|
377
|
+
} catch (e) {
|
|
378
|
+
return next(e)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Express request handler for installing a plugin (also used for updating via zip upload).
|
|
385
|
+
* @param {external:ExpressRequest} req
|
|
386
|
+
* @param {external:ExpressResponse} res
|
|
387
|
+
* @param {Function} next
|
|
388
|
+
*/
|
|
389
|
+
async installHandler (req, res, next) {
|
|
390
|
+
try {
|
|
391
|
+
const [pluginData] = await this.installPlugins([
|
|
392
|
+
[
|
|
393
|
+
req.body.name,
|
|
394
|
+
req?.fileUpload?.files?.file?.[0]?.filepath ?? req.body.version
|
|
395
|
+
]
|
|
396
|
+
], {
|
|
397
|
+
force: req.body.force === 'true' || req.body.force === true,
|
|
398
|
+
strict: true
|
|
399
|
+
})
|
|
400
|
+
res.status(this.mapStatusCode('post')).send(pluginData)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (error.code === this.app.errors.CONTENTPLUGIN_INSTALL_FAILED.code) {
|
|
403
|
+
error.data.errors = error.data.errors.map(req.translate)
|
|
404
|
+
}
|
|
405
|
+
res.sendError(error)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Express request handler for updating a plugin
|
|
411
|
+
* @param {external:ExpressRequest} req
|
|
412
|
+
* @param {external:ExpressResponse} res
|
|
413
|
+
* @param {Function} next
|
|
414
|
+
*/
|
|
415
|
+
async updateHandler (req, res, next) {
|
|
416
|
+
try {
|
|
417
|
+
const pluginData = await this.updatePlugin(req.params._id)
|
|
418
|
+
res.status(this.mapStatusCode('put')).send(pluginData)
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return next(error)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Express request handler for retrieving uses of a single plugin
|
|
426
|
+
* @param {external:ExpressRequest} req
|
|
427
|
+
* @param {external:ExpressResponse} res
|
|
428
|
+
* @param {Function} next
|
|
429
|
+
*/
|
|
430
|
+
async usesHandler (req, res, next) {
|
|
431
|
+
try {
|
|
432
|
+
const data = await this.getPluginUses(req.params._id)
|
|
433
|
+
res.status(this.mapStatusCode('put')).send(data)
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return next(error)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export default ContentPluginModule
|