adapt-authoring-content 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/errors/errors.json +18 -0
- package/index.js +5 -0
- package/lib/ContentModule.js +384 -0
- package/lib/apidefs.js +50 -0
- package/package.json +14 -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,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"INVALID_PARENT": {
|
|
3
|
+
"data": {
|
|
4
|
+
"parentId": "_id of the parent item"
|
|
5
|
+
},
|
|
6
|
+
"description": "Specified item is not a valid content item Invalid parent itemparent",
|
|
7
|
+
"statusCode": 500
|
|
8
|
+
},
|
|
9
|
+
"UNKNOWN_SCHEMA_NAME": {
|
|
10
|
+
"data": {
|
|
11
|
+
"_id": "The database _id",
|
|
12
|
+
"_type": "The _type value",
|
|
13
|
+
"_component": "The _component value"
|
|
14
|
+
},
|
|
15
|
+
"description": "Failed to determine schema name",
|
|
16
|
+
"statusCode": 500
|
|
17
|
+
}
|
|
18
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { AbstractApiModule, AbstractApiUtils } from 'adapt-authoring-api'
|
|
2
|
+
import apidefs from './apidefs.js'
|
|
3
|
+
/**
|
|
4
|
+
* Module which handles course content
|
|
5
|
+
* @memberof content
|
|
6
|
+
* @extends {AbstractApiModule}
|
|
7
|
+
*/
|
|
8
|
+
class ContentModule extends AbstractApiModule {
|
|
9
|
+
/** @override */
|
|
10
|
+
async setValues () {
|
|
11
|
+
const server = await this.app.waitForModule('server')
|
|
12
|
+
/** @ignore */ this.root = 'content'
|
|
13
|
+
/** @ignore */ this.collectionName = 'content'
|
|
14
|
+
/** @ignore */ this.schemaName = 'content'
|
|
15
|
+
/** @ignore */ this.router = server.api.createChildRouter('content')
|
|
16
|
+
this.useDefaultRouteConfig()
|
|
17
|
+
/** @ignore */ this.routes = [
|
|
18
|
+
{
|
|
19
|
+
route: '/insertrecusive',
|
|
20
|
+
handlers: { post: this.handleInsertRecursive.bind(this) },
|
|
21
|
+
permissions: { post: ['write:content'] },
|
|
22
|
+
meta: apidefs.insertrecursive
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
route: '/clone',
|
|
26
|
+
handlers: { post: this.handleClone.bind(this) },
|
|
27
|
+
permissions: { post: ['write:content'] },
|
|
28
|
+
meta: apidefs.clone
|
|
29
|
+
},
|
|
30
|
+
...this.routes
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @override */
|
|
35
|
+
async init () {
|
|
36
|
+
await super.init()
|
|
37
|
+
|
|
38
|
+
const [authored, jsonschema, mongodb, tags] = await this.app.waitForModule('authored', 'jsonschema', 'mongodb', 'tags')
|
|
39
|
+
|
|
40
|
+
await authored.registerModule(this)
|
|
41
|
+
await tags.registerModule(this)
|
|
42
|
+
/**
|
|
43
|
+
* we have to extend config specifically here because it doesn't use the default content schema
|
|
44
|
+
*/
|
|
45
|
+
jsonschema.registerSchemasHook.tap(this.registerConfigSchemas.bind(this))
|
|
46
|
+
await this.registerConfigSchemas()
|
|
47
|
+
|
|
48
|
+
await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @override */
|
|
52
|
+
async getSchemaName (data) {
|
|
53
|
+
const contentplugin = await this.app.waitForModule('contentplugin')
|
|
54
|
+
let { _component, _id, _type } = data
|
|
55
|
+
const defaultSchemaName = super.getSchemaName(data)
|
|
56
|
+
|
|
57
|
+
if (_id && (!_type || !_component)) { // no explicit type, so look for record in the DB
|
|
58
|
+
const [item] = await this.find({ _id }, { validate: false })
|
|
59
|
+
if (item) {
|
|
60
|
+
_type = item._type
|
|
61
|
+
_component = item._component
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!_type && !_component) { // can't go any further, return default value
|
|
65
|
+
return defaultSchemaName
|
|
66
|
+
}
|
|
67
|
+
if (_type !== 'component') {
|
|
68
|
+
return _type === 'page' || _type === 'menu' ? 'contentobject' : _type
|
|
69
|
+
}
|
|
70
|
+
const [component] = await contentplugin.find({ name: _component }, { validate: false })
|
|
71
|
+
return component ? `${component.targetAttribute.slice(1)}-component` : defaultSchemaName
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @override */
|
|
75
|
+
async getSchema (schemaName, data) {
|
|
76
|
+
const jsonschema = await this.app.waitForModule('jsonschema')
|
|
77
|
+
try { // try and determine a more specific schema
|
|
78
|
+
schemaName = await this.getSchemaName(data)
|
|
79
|
+
} catch (e) {}
|
|
80
|
+
const contentplugin = await this.app.waitForModule('contentplugin')
|
|
81
|
+
const _courseId = data._courseId ??
|
|
82
|
+
(data._id ? (await this.find({ _id: data._id }, { validate: false }))[0]?._courseId : undefined)
|
|
83
|
+
let enabledPluginSchemas = []
|
|
84
|
+
if (_courseId) {
|
|
85
|
+
try {
|
|
86
|
+
const [config] = await this.find({ _type: 'config', _courseId }, { validate: false })
|
|
87
|
+
enabledPluginSchemas = config._enabledPlugins.reduce((m, p) => [...m, ...contentplugin.getPluginSchemas(p)], [])
|
|
88
|
+
} catch (e) {}
|
|
89
|
+
}
|
|
90
|
+
return jsonschema.getSchema(schemaName, {
|
|
91
|
+
useCache: false,
|
|
92
|
+
extensionFilter: s => contentplugin.isPluginSchema(s) ? enabledPluginSchemas.includes(s) : true
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Adds config schema extensions
|
|
98
|
+
*/
|
|
99
|
+
async registerConfigSchemas () {
|
|
100
|
+
const [authored, jsonschema, tags] = await this.app.waitForModule('authored', 'jsonschema', 'tags')
|
|
101
|
+
jsonschema.extendSchema('config', authored.schemaName)
|
|
102
|
+
jsonschema.extendSchema('config', tags.schemaExtensionName)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @override */
|
|
106
|
+
async insert (data, options = {}, mongoOptions = {}) {
|
|
107
|
+
const doc = await super.insert(data, options, mongoOptions)
|
|
108
|
+
|
|
109
|
+
if (doc._type === 'course') { // add the _courseId to a new course to make querying easier
|
|
110
|
+
return this.update({ _id: doc._id }, { _courseId: doc._id.toString() })
|
|
111
|
+
}
|
|
112
|
+
await Promise.all([
|
|
113
|
+
options.updateSortOrder !== false && this.updateSortOrder(doc, data),
|
|
114
|
+
options.updateEnabledPlugins !== false && this.updateEnabledPlugins(doc)
|
|
115
|
+
])
|
|
116
|
+
return doc
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @override */
|
|
120
|
+
async update (query, data, options, mongoOptions) {
|
|
121
|
+
const doc = await super.update(query, data, options, mongoOptions)
|
|
122
|
+
await Promise.all([
|
|
123
|
+
this.updateSortOrder(doc, data),
|
|
124
|
+
this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
|
|
125
|
+
])
|
|
126
|
+
return doc
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @override */
|
|
130
|
+
async delete (query, options, mongoOptions) {
|
|
131
|
+
this.setDefaultOptions(options)
|
|
132
|
+
|
|
133
|
+
const [targetDoc] = await this.find(query)
|
|
134
|
+
|
|
135
|
+
if (!targetDoc) {
|
|
136
|
+
throw this.app.errors.NOT_FOUND.setData({ type: options.schemaName, id: JSON.stringify(query) })
|
|
137
|
+
}
|
|
138
|
+
const descendants = await this.getDescendants(targetDoc)
|
|
139
|
+
|
|
140
|
+
await Promise.all([...descendants, targetDoc].map(d => {
|
|
141
|
+
return super.delete({ _id: d._id })
|
|
142
|
+
}))
|
|
143
|
+
await Promise.all([
|
|
144
|
+
this.updateEnabledPlugins(targetDoc),
|
|
145
|
+
this.updateSortOrder(targetDoc)
|
|
146
|
+
])
|
|
147
|
+
return [targetDoc, ...descendants]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Finds all descendant content items for a given root
|
|
152
|
+
* @param {Object} rootItem The root item document
|
|
153
|
+
* @returns {Array<Object>} Array of content items
|
|
154
|
+
*/
|
|
155
|
+
async getDescendants (rootItem) {
|
|
156
|
+
const courseItems = await this.find({ _courseId: rootItem._courseId })
|
|
157
|
+
const descendants = []
|
|
158
|
+
let items = [rootItem]
|
|
159
|
+
do {
|
|
160
|
+
items = items.reduce((m, i) => [...m, ...courseItems.filter(c => c._parentId?.toString() === i._id.toString())], [])
|
|
161
|
+
descendants.push(...items)
|
|
162
|
+
} while (items.length)
|
|
163
|
+
|
|
164
|
+
if (rootItem._type === 'course') {
|
|
165
|
+
const config = courseItems.find(c => c._type === 'config')
|
|
166
|
+
if (config) descendants.push(config)
|
|
167
|
+
}
|
|
168
|
+
return descendants
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Creates a new parent content type, along with any necessary children
|
|
173
|
+
* @param {external:ExpressRequest} req
|
|
174
|
+
*/
|
|
175
|
+
async insertRecursive (req) {
|
|
176
|
+
const rootId = req.apiData.query.rootId
|
|
177
|
+
const createdBy = req.auth.user._id.toString()
|
|
178
|
+
let childTypes = ['course', 'page', 'article', 'block', 'component']
|
|
179
|
+
const defaultData = {
|
|
180
|
+
page: { title: req.translate('app.newpagetitle') },
|
|
181
|
+
article: { title: req.translate('app.newarticletitle') },
|
|
182
|
+
block: { title: req.translate('app.newblocktitle') },
|
|
183
|
+
component: {
|
|
184
|
+
_component: 'adapt-contrib-text',
|
|
185
|
+
_layout: 'full',
|
|
186
|
+
title: req.translate('app.newtextcomponenttitle'),
|
|
187
|
+
body: req.translate('app.newtextcomponentbody')
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const newItems = []
|
|
191
|
+
let parent
|
|
192
|
+
try {
|
|
193
|
+
// figure out which children need creating
|
|
194
|
+
if (rootId === undefined) { // new course
|
|
195
|
+
parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
|
|
196
|
+
newItems.push(parent)
|
|
197
|
+
childTypes.splice(0, 1, 'config')
|
|
198
|
+
} else {
|
|
199
|
+
parent = (await this.find({ _id: rootId }))[0]
|
|
200
|
+
// special case for menus
|
|
201
|
+
req.body?._type === 'menu'
|
|
202
|
+
? childTypes.splice(0, 1, 'menu')
|
|
203
|
+
: childTypes = childTypes.slice(childTypes.indexOf(parent._type) + 1)
|
|
204
|
+
}
|
|
205
|
+
for (const _type of childTypes) {
|
|
206
|
+
const data = Object.assign({ _type, createdBy }, defaultData[_type])
|
|
207
|
+
if (parent) {
|
|
208
|
+
Object.assign(data, {
|
|
209
|
+
_parentId: parent._id.toString(),
|
|
210
|
+
_courseId: parent._courseId.toString()
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
const item = await this.insert(data)
|
|
214
|
+
newItems.push(item)
|
|
215
|
+
if (_type !== 'config') parent = item
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
|
|
219
|
+
throw e
|
|
220
|
+
}
|
|
221
|
+
// return the topmost new item
|
|
222
|
+
return newItems[0]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Recursively clones a content item
|
|
227
|
+
* @param {String} userId The user performing the action
|
|
228
|
+
* @param {String} _id ID of the object to clone
|
|
229
|
+
* @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
|
|
230
|
+
* @param {Object} customData Data to be applied to the content item
|
|
231
|
+
* @return {Promise}
|
|
232
|
+
*/
|
|
233
|
+
async clone (userId, _id, _parentId, customData = {}) {
|
|
234
|
+
const [originalDoc] = await this.find({ _id })
|
|
235
|
+
if (!originalDoc) {
|
|
236
|
+
throw this.app.errors.NOT_FOUND
|
|
237
|
+
.setData({ type: originalDoc?._type, id: _id })
|
|
238
|
+
}
|
|
239
|
+
const [parent] = _parentId ? await this.find({ _id: _parentId }) : []
|
|
240
|
+
|
|
241
|
+
if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
|
|
242
|
+
throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
|
|
243
|
+
}
|
|
244
|
+
const schemaName = originalDoc._type === 'menu' || originalDoc._type === 'page' ? 'contentobject' : originalDoc._type
|
|
245
|
+
const payload = AbstractApiUtils.stringifyValues({
|
|
246
|
+
...originalDoc,
|
|
247
|
+
_id: undefined,
|
|
248
|
+
_trackingId: undefined,
|
|
249
|
+
_courseId: parent?._type === 'course' ? parent?._id : parent?._courseId,
|
|
250
|
+
_parentId,
|
|
251
|
+
createdBy: userId,
|
|
252
|
+
...customData
|
|
253
|
+
})
|
|
254
|
+
const newData = await this.insert(payload, { schemaName })
|
|
255
|
+
|
|
256
|
+
if (originalDoc._type === 'course') {
|
|
257
|
+
const [config] = await this.find({ _type: 'config', _courseId: originalDoc._courseId })
|
|
258
|
+
await this.clone(userId, config._id, undefined, { _courseId: newData._id.toString() })
|
|
259
|
+
delete payload._id
|
|
260
|
+
delete payload._courseId
|
|
261
|
+
// the config did not exist when the new course object was created
|
|
262
|
+
// schema validation will have therefore stripped the plugin configuration data
|
|
263
|
+
// here we restore the configuraton data
|
|
264
|
+
await this.update({ _id: newData._id }, payload)
|
|
265
|
+
}
|
|
266
|
+
const children = await this.find({ _parentId: _id })
|
|
267
|
+
for (let i = 0; i < children.length; i++) {
|
|
268
|
+
await this.clone(userId, children[i]._id, newData._id)
|
|
269
|
+
}
|
|
270
|
+
return newData
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Recalculates the _sortOrder values for all content items affected by an update
|
|
275
|
+
* @param {Object} item The existing item data
|
|
276
|
+
* @param {Object} updateData The update data
|
|
277
|
+
* @return {Promise}
|
|
278
|
+
*/
|
|
279
|
+
async updateSortOrder (item, updateData) {
|
|
280
|
+
// some exceptions which don't need a _sortOrder
|
|
281
|
+
if (item._type === 'config' || item._type === 'course' || !item._parentId) {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const siblings = await this.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 } })
|
|
285
|
+
if (updateData) {
|
|
286
|
+
const newSO = item._sortOrder - 1 > -1 ? item._sortOrder - 1 : siblings.length
|
|
287
|
+
siblings.splice(newSO, 0, item)
|
|
288
|
+
}
|
|
289
|
+
return Promise.all(siblings.map(async (s, i) => {
|
|
290
|
+
const _sortOrder = i + 1
|
|
291
|
+
if (s._sortOrder !== _sortOrder) super.update({ _id: s._id }, { _sortOrder })
|
|
292
|
+
}))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Maintains the list of plugins used in the current course
|
|
297
|
+
* @param {Object} item The updated item
|
|
298
|
+
* @param {Object} options
|
|
299
|
+
* @param {Boolean} options.forceUpdate Forces an update of defaults regardless of whether the _enabledPlugins list has changed
|
|
300
|
+
* @return {Promise}
|
|
301
|
+
*/
|
|
302
|
+
async updateEnabledPlugins ({ _courseId }, options = {}) {
|
|
303
|
+
const [contentplugin, jsonschema] = await this.app.waitForModule('contentplugin', 'jsonschema')
|
|
304
|
+
const contentItems = await this.find({ _courseId })
|
|
305
|
+
const config = contentItems.find(c => c._type === 'config')
|
|
306
|
+
|
|
307
|
+
if (!config) {
|
|
308
|
+
return // can't continue if there's no config to update
|
|
309
|
+
}
|
|
310
|
+
const extensionNames = (await contentplugin.find({ type: 'extension' })).map(p => p.name)
|
|
311
|
+
const componentNames = (contentItems.filter(c => c._type === 'component')).map(c => c._component)
|
|
312
|
+
// generate unique list of used plugins
|
|
313
|
+
const _enabledPlugins = Array.from(new Set([
|
|
314
|
+
...config._enabledPlugins.filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
|
|
315
|
+
...componentNames,
|
|
316
|
+
config._menu,
|
|
317
|
+
config._theme
|
|
318
|
+
]))
|
|
319
|
+
if (options.forceUpdate !== true &&
|
|
320
|
+
config._enabledPlugins.length === _enabledPlugins.length &&
|
|
321
|
+
config._enabledPlugins.every(p => _enabledPlugins.includes(p))) {
|
|
322
|
+
return // return early if the lists already match
|
|
323
|
+
}
|
|
324
|
+
// generate list of used content types which need defaults applied
|
|
325
|
+
const types = _enabledPlugins
|
|
326
|
+
.filter(p => options.forceUpdate || !config._enabledPlugins.includes(p))
|
|
327
|
+
.reduce((m, p) => m.concat(contentplugin.getPluginSchemas(p)), [])
|
|
328
|
+
.reduce((types, pluginSchemaName) => {
|
|
329
|
+
const rawSchema = jsonschema.schemas[pluginSchemaName].raw
|
|
330
|
+
const type = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
|
|
331
|
+
return (type === 'contentobject' ? ['menu', 'page'] : [type]).reduce((m, t) => {
|
|
332
|
+
if (t && !m.includes(t)) m.push(t)
|
|
333
|
+
return m
|
|
334
|
+
}, types)
|
|
335
|
+
}, [])
|
|
336
|
+
// update config._enabledPlugins
|
|
337
|
+
await super.update({ _courseId, _type: 'config' }, { _enabledPlugins })
|
|
338
|
+
// update other affected content objects to ensure new defaults are applied
|
|
339
|
+
// note: due to the complex data, each must be updated separately rather than using updateMany
|
|
340
|
+
if (types.length > 0) {
|
|
341
|
+
const toUpdate = await super.find({ _courseId, _type: { $in: types } }, {})
|
|
342
|
+
return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {})))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Special request handler for bootstrapping a new content object with dummy content
|
|
348
|
+
* @param {external:ExpressRequest} req
|
|
349
|
+
* @param {external:ExpressResponse} res
|
|
350
|
+
* @param {Function} next
|
|
351
|
+
*/
|
|
352
|
+
async handleInsertRecursive (req, res, next) {
|
|
353
|
+
try {
|
|
354
|
+
res.status(201).json(await this.insertRecursive(req))
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return next(e)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Request handler for cloning content items
|
|
362
|
+
* @param {external:ExpressRequest} req
|
|
363
|
+
* @param {external:ExpressResponse} res
|
|
364
|
+
* @param {Function} next
|
|
365
|
+
* @return {Promise} Resolves with the cloned data
|
|
366
|
+
*/
|
|
367
|
+
async handleClone (req, res, next) {
|
|
368
|
+
try {
|
|
369
|
+
await this.checkAccess(req, req.apiData.query)
|
|
370
|
+
const { _id, _parentId } = req.body
|
|
371
|
+
|
|
372
|
+
const customData = { ...req.body }
|
|
373
|
+
delete customData._id
|
|
374
|
+
delete customData._parentId
|
|
375
|
+
|
|
376
|
+
const newData = await this.clone(req.auth.user._id, _id, _parentId, customData)
|
|
377
|
+
res.status(201).json(newData)
|
|
378
|
+
} catch (e) {
|
|
379
|
+
return next(e)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export default ContentModule
|
package/lib/apidefs.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
clone: {
|
|
3
|
+
post: {
|
|
4
|
+
summary: 'Clones a content item',
|
|
5
|
+
description: 'Duplicates a content item as well as all its children.',
|
|
6
|
+
responses: {
|
|
7
|
+
201: {
|
|
8
|
+
description: 'The newly cloned data',
|
|
9
|
+
content: {
|
|
10
|
+
'application/json': {
|
|
11
|
+
schema: {
|
|
12
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
13
|
+
type: 'array',
|
|
14
|
+
items: { $ref: '#components/schemas/content' }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
insertrecursive: {
|
|
23
|
+
post: {
|
|
24
|
+
summary: 'Insert hierarchical content data',
|
|
25
|
+
description: 'Recursively inserts content data',
|
|
26
|
+
parameters: [{ name: 'rootId', in: 'path', description: 'The parent content item _id', required: true }],
|
|
27
|
+
requestBody: {
|
|
28
|
+
content: {
|
|
29
|
+
'application/json': {
|
|
30
|
+
schema: { $ref: '#components/schemas/content' }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
responses: {
|
|
35
|
+
201: {
|
|
36
|
+
description: 'The newly inserted data',
|
|
37
|
+
content: {
|
|
38
|
+
'application/json': {
|
|
39
|
+
schema: {
|
|
40
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
41
|
+
type: 'array',
|
|
42
|
+
items: { $ref: '#components/schemas/content' }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "adapt-authoring-content",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Module for managing Adapt content",
|
|
5
|
+
"homepage": "https://github.com/adapt-security/adapt-authoring-content",
|
|
6
|
+
"license": "GPL-3.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"repository": "github:adapt-security/adapt-authoring-content",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"eslint": "^9.12.0",
|
|
12
|
+
"standard": "^17.1.0"
|
|
13
|
+
}
|
|
14
|
+
}
|