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.
@@ -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.syncPluginData()
149
-
150
- const missing = await this.getMissingPlugins()
151
- if (missing.length) { // note we're using CLI directly, as plugins already exist in the DB
152
- this.log('debug', 'MISSING', missing)
153
- await this.framework.runCliCommand('installPlugins', { plugins: missing })
154
- }
155
- await this.processPluginSchemas()
156
- }
157
-
158
- /**
159
- * Makes sure the database plugin data is in sync with the currently installed framework plugins
160
- */
161
- async syncPluginData() {
162
- const dbInfo = (await this.find()).reduce((memo, info) => Object.assign(memo, { [info.name]: info }), {})
163
- for (const i of await this.framework.runCliCommand('getPluginUpdateInfos')) {
164
- if(dbInfo[i.name]?.version !== i.matchedVersion) {
165
- this.log('debug', 'SYNC', i.name, 'local:', dbInfo[i.name]?.version, 'fw:', i.matchedVersion)
166
- await this.insertOrUpdate({ ...(await i.getInfo()), type: await i.getType(), isLocalInstall: i.isLocalSource })
167
- }
168
- }
169
- }
170
-
171
- /**
172
- * Returns a list of plugins defined in the database but not installed in the framework
173
- * @return {Array} List of plugin names mapped to version/dir
174
- */
175
- async getMissingPlugins() {
176
- const dbPlugins = await this.find()
177
- const fwPlugins = await this.framework.getInstalledPlugins()
178
- return dbPlugins
179
- .filter(dbP => !fwPlugins.find(fwP => dbP.name === fwP.name))
180
- .map(p => `${p.name}@${p.isLocalInstall ? path.join(this.getConfig('pluginDir'), p.name) : p.version}`)
181
- }
182
-
183
- /**
184
- * Loads and processes all installed content plugin schemas
185
- * @param {Array} pluginInfo Plugin info data
186
- * @return {Promise}
187
- */
188
- async processPluginSchemas (pluginInfo) {
189
- if (!pluginInfo) {
190
- pluginInfo = await this.framework.runCliCommand('getPluginUpdateInfos')
191
- }
192
- const jsonschema = await this.app.waitForModule('jsonschema')
193
- return Promise.all(pluginInfo.map(async plugin => {
194
- const name = plugin.name
195
- const oldSchemaPaths = this.pluginSchemas[name]
196
- if (oldSchemaPaths) {
197
- Object.values(oldSchemaPaths).forEach(s => jsonschema.deregisterSchema(s))
198
- delete this.pluginSchemas[name]
199
- }
200
- const schemaPaths = await plugin.getSchemaPaths()
201
- return Promise.all(schemaPaths.map(async schemaPath => {
202
- const schema = await this.readJson(schemaPath)
203
- const source = schema?.$patch?.source?.$ref
204
- if (source) {
205
- if (!this.pluginSchemas[name]) this.pluginSchemas[name] = []
206
- if (this.pluginSchemas[name].includes(schema.$anchor)) jsonschema.deregisterSchema(this.pluginSchemas[name][source])
207
- this.pluginSchemas[name].push(schema.$anchor)
208
- }
209
- return jsonschema.registerSchema(schemaPath, { replace: true })
210
- }))
211
- }))
212
- }
213
-
214
- /**
215
- * Returns whether a schema is registered by a plugin
216
- * @param {String} schemaName Name of the schema to check
217
- * @return {Boolean}
218
- */
219
- isPluginSchema (schemaName) {
220
- for (const p in this.pluginSchemas) {
221
- if (this.pluginSchemas[p].includes(schemaName)) return true
222
- }
223
- }
224
-
225
- /**
226
- * Returns all schemas registered by a plugin
227
- * @param {String} pluginName Plugin name
228
- * @return {Array} List of the plugin's registered schemas
229
- */
230
- getPluginSchemas (pluginName) {
231
- return this.pluginSchemas[pluginName] ?? []
232
- }
233
-
234
- /**
235
- * Retrieves the courses in which a plugin is used
236
- * @param {String} pluginId Plugin _id
237
- * @returns {Promise} Resolves with an array of course data
238
- */
239
- async getPluginUses (pluginId) {
240
- const [{ name }] = await this.find({ _id: pluginId })
241
- const [content, db] = await this.app.waitForModule('content', 'mongodb')
242
- return (db.getCollection(content.collectionName).aggregate([
243
- { $match: { _type: 'config', _enabledPlugins: name } },
244
- { $lookup: { from: 'content', localField: '_courseId', foreignField: '_id', as: 'course' } },
245
- { $unwind: '$course' },
246
- { $replaceRoot: { newRoot: '$course' } },
247
- { $lookup: { from: 'users', localField: 'createdBy', foreignField: '_id', as: 'createdBy' } },
248
- { $project: { title: 1, createdBy: { $map: { input: '$createdBy', as: 'user', in: '$$user.email' } } } },
249
- { $unwind: '$createdBy' }
250
- ])).toArray()
251
- }
252
-
253
- /**
254
- * Installs new plugins
255
- * @param {Array[]} plugins 2D array of strings in the format [pluginName, versionOrPath]
256
- * @param {Object} options
257
- * @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
258
- * @param {Boolean} options.strict Whether the function should fail on error
259
- */
260
- async installPlugins (plugins, options = { strict: false, force: false }) {
261
- const errors = []
262
- const installed = []
263
- await Promise.all(plugins.map(async ([name, versionOrPath]) => {
264
- try {
265
- const data = await this.installPlugin(name, versionOrPath, options)
266
- installed.push(data)
267
- this.log('info', 'PLUGIN_INSTALL', `${data.name}@${data.version}`)
268
- } catch (e) {
269
- this.log('warn', 'PLUGIN_INSTALL_FAIL', name, e?.data?.error ?? e)
270
- errors.push(e)
271
- }
272
- }))
273
- if (errors.length && options.strict) {
274
- throw this.app.errors.CONTENTPLUGIN_INSTALL_FAILED
275
- .setData({ errors })
276
- }
277
- return installed
278
- }
279
-
280
- /**
281
- * Installs a single plugin. Note: this function is called by installPlugins and should not be called directly.
282
- * @param {String} pluginName Name of the plugin to install
283
- * @param {String} versionOrPath The semver-formatted version, or the path to the plugin source
284
- * @param {Object} options
285
- * @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
286
- * @param {Boolean} options.strict Whether the function should fail on error
287
- * @returns Resolves with plugin DB data
288
- */
289
- async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) {
290
- const [pluginData] = await this.find({ name: String(pluginName) }, { includeUpdateInfo: true })
291
- const { name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath })
292
- const [ existingPlugin ] = await this.find({ name })
293
-
294
- if (existingPlugin) {
295
- if (!options.force && semver.lte(version, existingPlugin.version)) {
296
- throw this.app.errors.CONTENTPLUGIN_ALREADY_EXISTS
297
- .setData({ name: existingPlugin.name, version: existingPlugin.version })
298
- }
299
- }
300
- const [data] = await this.framework.runCliCommand('installPlugins', { plugins: [`${name}@${sourcePath ?? version}`] })
301
- const info = await this.insertOrUpdate({
302
- ...(await data.getInfo()),
303
- type: await data.getType(),
304
- isLocalInstall
305
- })
306
- if (!data.isInstallSuccessful) {
307
- throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED
308
- .setData({ name })
309
- }
310
- if (!info.targetAttribute) {
311
- throw this.app.errors.CONTENTPLUGIN_ATTR_MISSING
312
- .setData({ name })
313
- }
314
- await this.processPluginSchemas([data])
315
- return info
316
- }
317
-
318
- /**
319
- * Ensures local plugin source files are stored in the correct location and structured in an expected way
320
- * @param {Object} pluginData Plugin metadata
321
- * @param {String} sourcePath The path to the plugin source files
322
- * @returns Resolves with package data
323
- */
324
- async processPluginFiles (pluginData) {
325
- let sourcePath = pluginData.sourcePath
326
- if (sourcePath === path.basename(sourcePath)) { // no local files
327
- return { name: pluginData.name, version: sourcePath, isLocalInstall: false }
328
- }
329
- const contents = await fs.readdir(sourcePath)
330
- if (contents.length === 1) { // deal with a nested root folder
331
- sourcePath = path.join(pluginData.sourcePath, contents[0])
332
- }
333
- let pkg
334
- try { // load package data, with fall-back to bower
335
- try {
336
- pkg = await this.readJson(path.join(sourcePath, 'package.json'))
337
- } catch (e) {
338
- pkg = await this.readJson(path.join(sourcePath, 'bower.json'))
339
- }
340
- pkg.sourcePath = path.join(this.getConfig('pluginDir'), pkg.name)
341
- pkg.isLocalInstall = true
342
- } catch (e) {
343
- throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
344
- }
345
- // move the files into the persistent location
346
- await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
347
- await fs.rm(sourcePath, { recursive: true })
348
- return pkg
349
- }
350
-
351
- /**
352
- * Updates a single plugin
353
- * @param {String} _id The _id for the plugin to update
354
- * @return Resolves with update data
355
- */
356
- async updatePlugin (_id) {
357
- const [{ name }] = await this.find({ _id })
358
- const [pluginData] = await this.framework.runCliCommand('updatePlugins', { plugins: [name] })
359
- const p = await this.update({ name }, pluginData._sourceInfo)
360
- await this.processPluginSchemas(pluginData)
361
- this.log('info', `successfully updated plugin ${p.name}@${p.version}`)
362
- return p
363
- }
364
-
365
- /** @override */
366
- serveSchema () {
367
- return async (req, res, next) => {
368
- try {
369
- const plugin = await this.get({ name: req.apiData.query.type }) || {}
370
- const schema = await this.getSchema(plugin.schemaName)
371
- if (!schema) {
372
- return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
373
- }
374
- res.type('application/schema+json').json(schema)
375
- } catch (e) {
376
- return next(e)
377
- }
378
- }
379
- }
380
-
381
- /**
382
- * Express request handler for installing a plugin (also used for updating via zip upload).
383
- * @param {external:ExpressRequest} req
384
- * @param {external:ExpressResponse} res
385
- * @param {Function} next
386
- */
387
- async installHandler (req, res, next) {
388
- try {
389
- const [pluginData] = await this.installPlugins([
390
- [
391
- req.body.name,
392
- req?.fileUpload?.files?.file?.[0]?.filepath ?? req.body.version
393
- ]
394
- ], {
395
- force: req.body.force === 'true' || req.body.force === true,
396
- strict: true
397
- })
398
- res.status(this.mapStatusCode('post')).send(pluginData)
399
- } catch (error) {
400
- if (error.code === this.app.errors.CONTENTPLUGIN_INSTALL_FAILED.code) {
401
- error.data.errors = error.data.errors.map(req.translate)
402
- }
403
- res.sendError(error)
404
- }
405
- }
406
-
407
- /**
408
- * Express request handler for updating a plugin
409
- * @param {external:ExpressRequest} req
410
- * @param {external:ExpressResponse} res
411
- * @param {Function} next
412
- */
413
- async updateHandler (req, res, next) {
414
- try {
415
- const pluginData = await this.updatePlugin(req.params._id)
416
- res.status(this.mapStatusCode('put')).send(pluginData)
417
- } catch (error) {
418
- return next(error)
419
- }
420
- }
421
-
422
- /**
423
- * Express request handler for retrieving uses of a single plugin
424
- * @param {external:ExpressRequest} req
425
- * @param {external:ExpressResponse} res
426
- * @param {Function} next
427
- */
428
- async usesHandler (req, res, next) {
429
- try {
430
- const data = await this.getPluginUses(req.params._id)
431
- res.status(this.mapStatusCode('put')).send(data)
432
- } catch (error) {
433
- return next(error)
434
- }
435
- }
436
- }
437
-
438
- export default ContentPluginModule
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