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