adapt-authoring-contentplugin 0.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 ADDED
@@ -0,0 +1 @@
1
+ node_modules
package/.eslintrc ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "env": {
3
+ "browser": false,
4
+ "node": true,
5
+ "commonjs": false,
6
+ "es2020": true
7
+ },
8
+ "extends": [
9
+ "standard"
10
+ ],
11
+ "parserOptions": {
12
+ "ecmaVersion": 2020
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ name: Bug Report
2
+ description: File a bug report
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to fill out this bug report!
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: What happened?
13
+ description: Please describe the issue
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: expected
18
+ attributes:
19
+ label: Expected behaviour
20
+ description: Tell us what should have happened
21
+ - type: textarea
22
+ id: repro-steps
23
+ attributes:
24
+ label: Steps to reproduce
25
+ description: Tell us how to reproduce the issue
26
+ validations:
27
+ required: true
28
+ - type: input
29
+ id: aat-version
30
+ attributes:
31
+ label: Authoring tool version
32
+ description: What version of the Adapt authoring tool are you running?
33
+ validations:
34
+ required: true
35
+ - type: input
36
+ id: fw-version
37
+ attributes:
38
+ label: Framework version
39
+ description: What version of the Adapt framework are you running?
40
+ - type: dropdown
41
+ id: browsers
42
+ attributes:
43
+ label: What browsers are you seeing the problem on?
44
+ multiple: true
45
+ options:
46
+ - Firefox
47
+ - Chrome
48
+ - Safari
49
+ - Microsoft Edge
50
+ - type: textarea
51
+ id: logs
52
+ attributes:
53
+ label: Relevant log output
54
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
+ render: sh
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,22 @@
1
+ name: Feature request
2
+ description: Request a new feature
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: Feature description
13
+ description: Please describe your feature request
14
+ validations:
15
+ required: true
16
+ - type: checkboxes
17
+ id: contribute
18
+ attributes:
19
+ label: Can you work on this feature?
20
+ description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
+ options:
22
+ - label: I can contribute
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,25 @@
1
+ [//]: # (Please title your PR according to eslint commit conventions)
2
+ [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
+
4
+ [//]: # (Add a link to the original issue)
5
+
6
+ [//]: # (Delete as appropriate)
7
+ ### Fix
8
+ * A sentence describing each fix
9
+
10
+ ### Update
11
+ * A sentence describing each udpate
12
+
13
+ ### New
14
+ * A sentence describing each new feature
15
+
16
+ ### Breaking
17
+ * A sentence describing each breaking change
18
+
19
+ [//]: # (List appropriate steps for testing if needed)
20
+ ### Testing
21
+ 1. Steps for testing
22
+
23
+ [//]: # (Mention any other dependencies)
24
+
25
+
@@ -0,0 +1,16 @@
1
+ name: Add labelled PRs to project
2
+
3
+ on:
4
+ pull_request:
5
+ types: [ labeled ]
6
+
7
+ jobs:
8
+ add-to-project:
9
+ if: ${{ github.event.label.name == 'dependencies' }}
10
+ name: Add to main project
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/add-to-project@v0.1.0
14
+ with:
15
+ project-url: https://github.com/orgs/adapt-security/projects/5
16
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,19 @@
1
+ name: Add to main project
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ name: Add to main project
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/add-to-project@v0.1.0
17
+ with:
18
+ project-url: https://github.com/orgs/adapt-security/projects/5
19
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,5 @@
1
+ {
2
+ "documentation": {
3
+ "enable": true
4
+ }
5
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "pluginDir": {
6
+ "description": "Location of locally installed plugins",
7
+ "type": "string",
8
+ "isDirectory": true,
9
+ "default": "$DATA/contentplugins"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "CONTENTPLUGIN_ALREADY_EXISTS": {
3
+ "data": {
4
+ "name": "Name of content plugin",
5
+ "version": "Version of content plugin"
6
+ },
7
+ "description": "Plugin already exists with the same name and version",
8
+ "statusCode": 400
9
+ },
10
+ "CONTENTPLUGIN_ATTR_CLASH": {
11
+ "data": {
12
+ "name": "Name of content plugin",
13
+ "targetAttribute": "Offending target attribute"
14
+ },
15
+ "description": "Target attribute already used in another plugin",
16
+ "statusCode": 400
17
+ },
18
+ "CONTENTPLUGIN_ATTR_MISSING": {
19
+ "data": {
20
+ "name": "Name of content plugin"
21
+ },
22
+ "description": "Pluginis missing targetAttribute value",
23
+ "statusCode": 400
24
+ },
25
+ "CONTENTPLUGIN_INCOMPAT_FW": {
26
+ "data": {
27
+ "installedFramework": "Version of the framework which is installed",
28
+ "name": "Name of content plugin",
29
+ "requiredFramework": "Version of the framework which is required",
30
+ "version": "Version of content plugin"
31
+ },
32
+ "description": "Plugin incompatible with the installed framework version",
33
+ "statusCode": 400
34
+ },
35
+ "CONTENTPLUGIN_CLI_INSTALL_FAILED": {
36
+ "data": {
37
+ "name": "Name of content plugin"
38
+ },
39
+ "description": "Installation via adapt-cli failed",
40
+ "statusCode": 500
41
+ },
42
+ "CONTENTPLUGIN_IN_USE": {
43
+ "data": {
44
+ "courses": "List of courses using the content plugin"
45
+ },
46
+ "description": "Content plugin is in use in existing courses",
47
+ "statusCode": 400
48
+ },
49
+ "CONTENTPLUGIN_INSTALL_FAILED": {
50
+ "data": {
51
+ "errors": "List of errors"
52
+ },
53
+ "description": "Installation of plugins failed",
54
+ "statusCode": 500
55
+ },
56
+ "CONTENTPLUGIN_INVALID_ZIP": {
57
+ "description": "Invalid plugin data provided",
58
+ "statusCode": 400
59
+ },
60
+ "CONTENTPLUGIN_NEWER_INSTALLED": {
61
+ "data": {
62
+ "existingVersion": "Installed version",
63
+ "name": "Name of content plugin",
64
+ "newVersion": "Version to install"
65
+ },
66
+ "description": "Plugin already exists at a higher version",
67
+ "statusCode": 400
68
+ },
69
+ "CONTENTPLUGIN_VERSION_MISMATCH": {
70
+ "data": {
71
+ "registered": "Plugins that do not match their installed version"
72
+ },
73
+ "description": "The installed version of a plugin does not match the registered version",
74
+ "statusCode": 400
75
+ }
76
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Content plugin functionality
3
+ * @namespace contentplugin
4
+ */
5
+ export { default } from './lib/ContentPluginModule.js'
@@ -0,0 +1,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
+ 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
package/lib/apidefs.js ADDED
@@ -0,0 +1,67 @@
1
+ export default {
2
+ update: {
3
+ post: {
4
+ summary: 'Update a single content plugin',
5
+ parameters: [{ name: '_id', in: 'path', description: 'Content plugin _id', required: true }],
6
+ responses: {
7
+ 200: {
8
+ description: '',
9
+ content: {
10
+ 'application/json': {
11
+ schema: { $ref: '#components/schemas/contentplugin' }
12
+ }
13
+ }
14
+ }
15
+ }
16
+ }
17
+ },
18
+ uses: {
19
+ get: {
20
+ summary: 'Return courses using a single content plugin',
21
+ parameters: [{ name: '_id', in: 'path', description: 'Content plugin _id', required: true }],
22
+ responses: {
23
+ 200: {
24
+ description: '',
25
+ content: {
26
+ 'application/json': {
27
+ schema: {
28
+ type: 'array',
29
+ items: { $ref: '#components/schemas/course' }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ },
37
+ install: {
38
+ post: {
39
+ summary: 'Import an Adapt course',
40
+ requestBody: {
41
+ content: {
42
+ 'application/json': {
43
+ schema: {
44
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
45
+ type: 'object',
46
+ properties: {
47
+ name: { type: 'string' },
48
+ version: { type: 'string' },
49
+ force: { type: 'Boolean', default: false }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ responses: {
56
+ 200: {
57
+ description: '',
58
+ content: {
59
+ 'application/json': {
60
+ schema: { $ref: '#components/schemas/contentplugin' }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "adapt-authoring-contentplugin",
3
+ "version": "0.0.1",
4
+ "description": "Module for managing framework plugins",
5
+ "homepage": "https://github.com/adapt-security/adapt-authoring-contentplugin",
6
+ "main": "index.js",
7
+ "type": "module",
8
+ "dependencies": {
9
+ "adapt-cli": "github:adaptlearning/adapt-cli#v3.3.2",
10
+ "glob": "^11.0.0",
11
+ "semver": "^7.6.0"
12
+ },
13
+ "devDependencies": {
14
+ "eslint": "^9.12.0",
15
+ "standard": "^17.1.0"
16
+ }
17
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "contentplugin",
4
+ "description": "An Adapt framework plugin",
5
+ "type": "object",
6
+ "properties": {
7
+ "name": {
8
+ "description": "Unique name for the plugin",
9
+ "type": "string"
10
+ },
11
+ "displayName": {
12
+ "description": "User-friendly name for the plugin",
13
+ "type": "string"
14
+ },
15
+ "version": {
16
+ "description": "Version number for the plugin",
17
+ "type": "string"
18
+ },
19
+ "framework": {
20
+ "description": "",
21
+ "type": "string"
22
+ },
23
+ "isLocalInstall": {
24
+ "description": "Whether the plugin has been installed locally (as opposed to with the CLI)",
25
+ "type": "boolean"
26
+ },
27
+ "isEnabled": {
28
+ "description": "",
29
+ "type": "boolean",
30
+ "default": true
31
+ },
32
+ "type": {
33
+ "description": "",
34
+ "type": "string"
35
+ },
36
+ "targetAttribute": {
37
+ "description": "",
38
+ "type": "string"
39
+ },
40
+ "description": {
41
+ "description": "",
42
+ "type": "string"
43
+ },
44
+ "pluginDependencies": {
45
+ "description": "",
46
+ "type": "object"
47
+ },
48
+ "canBeUpdated" : {
49
+ "type": "boolean",
50
+ "isReadOnly": true
51
+ },
52
+ "latestCompatibleVersion" : {
53
+ "type": "boolean",
54
+ "isReadOnly": true
55
+ }
56
+ },
57
+ "required": ["framework", "name", "type", "version", "isLocalInstall"]
58
+ }