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 +1 -0
- package/.eslintrc +14 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +55 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +22 -0
- package/.github/dependabot.yml +11 -0
- package/.github/pull_request_template.md +25 -0
- package/.github/workflows/labelled_prs.yml +16 -0
- package/.github/workflows/new.yml +19 -0
- package/adapt-authoring.json +5 -0
- package/conf/config.schema.json +12 -0
- package/errors/errors.json +76 -0
- package/index.js +5 -0
- package/lib/ContentPluginModule.js +438 -0
- package/lib/apidefs.js +67 -0
- package/package.json +17 -0
- package/schema/contentplugin.schema.json +58 -0
package/.eslintignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
node_modules
|
package/.eslintrc
ADDED
|
@@ -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,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,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
|
+
}
|