adapt-authoring-content 2.1.7 → 3.0.0
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/errors/errors.json +7 -8
- package/index.js +1 -0
- package/lib/ContentModule.js +432 -110
- package/lib/ContentTree.js +128 -0
- package/lib/utils/computeSortOrderOps.js +21 -0
- package/lib/utils/contentTypeToSchemaName.js +9 -0
- package/lib/utils/extractAssetIds.js +18 -0
- package/lib/utils/formatFriendlyId.js +12 -0
- package/lib/utils/parseMaxSeq.js +16 -0
- package/lib/utils.js +6 -1
- package/migrations/3.0.0.js +123 -0
- package/package.json +4 -3
- package/routes.json +51 -0
- package/schema/contentassets.schema.json +18 -0
- package/tests/ContentModule.spec.js +512 -1634
- package/tests/ContentTree.spec.js +230 -0
- package/tests/_ht.js +116 -0
- package/tests/utils-computeSortOrderOps.spec.js +94 -0
- package/tests/utils-contentTypeToSchemaName.spec.js +21 -0
- package/tests/utils-extractAssetIds.spec.js +118 -0
- package/tests/utils-formatFriendlyId.spec.js +40 -0
- package/tests/utils-parseMaxSeq.spec.js +49 -0
- package/lib/utils/getDescendants.js +0 -22
- package/tests/utils-getDescendants.spec.js +0 -117
package/lib/ContentModule.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AbstractApiModule } from 'adapt-authoring-api'
|
|
2
|
-
import { getDescendants } from './utils.js'
|
|
3
2
|
import { Hook, stringifyValues } from 'adapt-authoring-core'
|
|
3
|
+
import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
|
|
4
|
+
import { ObjectId } from 'mongodb'
|
|
5
|
+
import { ContentTree, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
|
|
4
6
|
/**
|
|
5
7
|
* Module which handles course content
|
|
6
8
|
* @memberof content
|
|
@@ -11,6 +13,7 @@ class ContentModule extends AbstractApiModule {
|
|
|
11
13
|
async setValues () {
|
|
12
14
|
await super.setValues()
|
|
13
15
|
/** @ignore */ this.collectionName = this.schemaName = 'content'
|
|
16
|
+
this.counterCollectionName = 'contentcounters'
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/** @override */
|
|
@@ -27,80 +30,217 @@ class ContentModule extends AbstractApiModule {
|
|
|
27
30
|
*/
|
|
28
31
|
this.postCloneHook = new Hook()
|
|
29
32
|
|
|
30
|
-
const [authored, jsonschema, mongodb, tags] = await this.app.waitForModule('authored', 'jsonschema', 'mongodb', 'tags')
|
|
33
|
+
const [assets, authored, contentplugin, jsonschema, mongodb, tags] = await this.app.waitForModule('assets', 'authored', 'contentplugin', 'jsonschema', 'mongodb', 'tags')
|
|
34
|
+
/** @ignore */ this.assets = assets
|
|
35
|
+
/** @ignore */ this.contentplugin = contentplugin
|
|
36
|
+
/** @ignore */ this.jsonschema = jsonschema
|
|
37
|
+
/** @ignore */ this.mongodb = mongodb
|
|
38
|
+
/** @ignore */ this.authored = authored
|
|
39
|
+
/** @ignore */ this.tags = tags
|
|
40
|
+
/** @ignore */ this._schemaCache = new Map()
|
|
31
41
|
|
|
32
42
|
await authored.registerModule(this)
|
|
33
43
|
await tags.registerModule(this)
|
|
34
44
|
/**
|
|
35
45
|
* we have to extend config specifically here because it doesn't use the default content schema
|
|
36
46
|
*/
|
|
37
|
-
jsonschema.registerSchemasHook.tap(
|
|
47
|
+
jsonschema.registerSchemasHook.tap(() => {
|
|
48
|
+
this._schemaCache.clear()
|
|
49
|
+
this.registerConfigSchemas()
|
|
50
|
+
jsonschema.extendSchema('content', 'contentassets')
|
|
51
|
+
})
|
|
38
52
|
await this.registerConfigSchemas()
|
|
53
|
+
jsonschema.extendSchema('content', 'contentassets')
|
|
54
|
+
|
|
55
|
+
// bump course.updatedAt so tree endpoint If-Modified-Since invalidates
|
|
56
|
+
this.postInsertHook.tap(this.touchCourse.bind(this))
|
|
57
|
+
this.postUpdateHook.tap((_, doc) => this.touchCourse(doc))
|
|
58
|
+
this.postDeleteHook.tap(this.touchCourse.bind(this))
|
|
59
|
+
|
|
60
|
+
assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))
|
|
39
61
|
|
|
40
62
|
await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
|
|
63
|
+
await mongodb.setIndex(this.collectionName, { _parentId: 1 })
|
|
64
|
+
await mongodb.setIndex(this.collectionName, { _type: 1, _courseId: 1 })
|
|
65
|
+
await mongodb.setIndex(this.collectionName, { _assetIds: 1 })
|
|
41
66
|
await mongodb.setIndex(this.collectionName, { _courseId: 1, _friendlyId: 1 }, {
|
|
42
67
|
unique: true,
|
|
43
68
|
partialFilterExpression: { _friendlyId: { $type: 'string', $gt: '' } }
|
|
44
69
|
})
|
|
70
|
+
await mongodb.setIndex(this.counterCollectionName, { _type: 1, _courseId: 1 }, { unique: true })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Touches the parent course's updatedAt so the tree endpoint's If-Modified-Since check invalidates after any descendant content changes.
|
|
75
|
+
* @param {Object} doc Content document that was inserted/updated/deleted
|
|
76
|
+
* @return {Promise}
|
|
77
|
+
*/
|
|
78
|
+
async touchCourse (doc) {
|
|
79
|
+
if (!doc || doc._type === 'course' || !doc._courseId) return
|
|
80
|
+
await this.mongodb.getCollection(this.collectionName).updateOne(
|
|
81
|
+
{ _id: parseObjectId(doc._courseId), _type: 'course' },
|
|
82
|
+
{ $set: { updatedAt: new Date() } }
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Refuses asset deletion when the asset is referenced by content. Throws RESOURCE_IN_USE listing the affected course titles.
|
|
88
|
+
* @param {Object} asset Asset document being deleted
|
|
89
|
+
* @return {Promise}
|
|
90
|
+
*/
|
|
91
|
+
async enforceAssetNotInUse (asset) {
|
|
92
|
+
const usedBy = await this.find(
|
|
93
|
+
{ _assetIds: asset._id.toString() },
|
|
94
|
+
{ validate: false },
|
|
95
|
+
{ projection: { _courseId: 1 } }
|
|
96
|
+
)
|
|
97
|
+
if (!usedBy.length) return
|
|
98
|
+
const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
|
|
99
|
+
const courses = (await this.find(
|
|
100
|
+
{ _type: 'course', _id: { $in: courseIds } },
|
|
101
|
+
{ validate: false },
|
|
102
|
+
{ projection: { title: 1, displayTitle: 1 } }
|
|
103
|
+
)).map(c => c.displayTitle || c.title)
|
|
104
|
+
throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
|
|
45
105
|
}
|
|
46
106
|
|
|
47
107
|
/** @override */
|
|
48
108
|
async getSchemaName (data) {
|
|
49
|
-
const contentplugin =
|
|
109
|
+
const { contentplugin } = this
|
|
50
110
|
let { _component, _id, _type } = data
|
|
51
111
|
const defaultSchemaName = super.getSchemaName(data)
|
|
52
112
|
|
|
53
113
|
if (_id && (!_type || !_component)) { // no explicit type, so look for record in the DB
|
|
54
|
-
const
|
|
114
|
+
const item = await this.findOne({ _id }, { validate: false, throwOnMissing: false }, { projection: { _type: 1, _component: 1, _courseId: 1 } })
|
|
55
115
|
if (item) {
|
|
56
116
|
_type = item._type
|
|
57
117
|
_component = item._component
|
|
118
|
+
if (!data._courseId && item._courseId) data._courseId = item._courseId
|
|
58
119
|
}
|
|
59
120
|
}
|
|
60
121
|
if (!_type && !_component) { // can't go any further, return default value
|
|
61
122
|
return defaultSchemaName
|
|
62
123
|
}
|
|
63
124
|
if (_type !== 'component') {
|
|
64
|
-
return _type
|
|
125
|
+
return contentTypeToSchemaName(_type)
|
|
65
126
|
}
|
|
66
|
-
const component = await contentplugin.findOne({ name: _component }, { validate: false,
|
|
127
|
+
const component = await contentplugin.findOne({ name: _component }, { validate: false, throwOnMissing: false })
|
|
67
128
|
return component ? `${component.targetAttribute.slice(1)}-component` : defaultSchemaName
|
|
68
129
|
}
|
|
69
130
|
|
|
70
131
|
/** @override */
|
|
71
132
|
async getSchema (schemaName, data) {
|
|
72
|
-
const jsonschema =
|
|
73
|
-
|
|
74
|
-
schemaName = await this.getSchemaName(data)
|
|
75
|
-
} catch (e) {}
|
|
76
|
-
const contentplugin = await this.app.waitForModule('contentplugin')
|
|
133
|
+
const { contentplugin, jsonschema } = this
|
|
134
|
+
schemaName = await this.getSchemaName(data)
|
|
77
135
|
const _courseId = data._courseId ??
|
|
78
|
-
(data._id ? (await this.
|
|
136
|
+
(data._id ? (await this.findOne({ _id: data._id }, { validate: false, throwOnMissing: false }, { projection: { _courseId: 1 } }))?._courseId : undefined)
|
|
79
137
|
let enabledPluginSchemas = []
|
|
80
138
|
if (_courseId) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
enabledPluginSchemas = pluginList.reduce((m, p) => [...m, ...contentplugin.getPluginSchemas(p)], [])
|
|
85
|
-
} catch (e) {}
|
|
139
|
+
const config = await this.findOne({ _type: 'config', _courseId }, { validate: false, throwOnMissing: false }, { projection: { _enabledPlugins: 1 } })
|
|
140
|
+
const pluginList = config?._enabledPlugins ?? data?._enabledPlugins ?? []
|
|
141
|
+
enabledPluginSchemas = pluginList.flatMap(p => contentplugin.getPluginSchemas(p))
|
|
86
142
|
}
|
|
87
|
-
|
|
143
|
+
const cacheKey = schemaName + ':' + enabledPluginSchemas.slice().sort().join(',')
|
|
144
|
+
const cached = this._schemaCache.get(cacheKey)
|
|
145
|
+
if (cached) return cached
|
|
146
|
+
|
|
147
|
+
const schema = await jsonschema.getSchema(schemaName, {
|
|
88
148
|
useCache: false,
|
|
89
149
|
extensionFilter: s => contentplugin.isPluginSchema(s) ? enabledPluginSchemas.includes(s) : true
|
|
90
150
|
})
|
|
151
|
+
this._schemaCache.set(cacheKey, schema)
|
|
152
|
+
return schema
|
|
91
153
|
}
|
|
92
154
|
|
|
93
155
|
/**
|
|
94
156
|
* Adds config schema extensions
|
|
95
157
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
jsonschema.extendSchema('config',
|
|
99
|
-
|
|
158
|
+
registerConfigSchemas () {
|
|
159
|
+
this.jsonschema.extendSchema('config', this.authored.schemaName)
|
|
160
|
+
this.jsonschema.extendSchema('config', this.tags.schemaExtensionName)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Generates multiple unique friendly IDs for a given type in a single atomic counter increment.
|
|
165
|
+
* @param {String} _type Content type (e.g. 'page', 'block', 'component')
|
|
166
|
+
* @param {String} _courseId The course these items belong to
|
|
167
|
+
* @param {Number} count Number of IDs to generate
|
|
168
|
+
* @param {String} [_language] Language code (only used for courses)
|
|
169
|
+
* @return {Promise<Array<String>>}
|
|
170
|
+
*/
|
|
171
|
+
async generateFriendlyIds (_type, _courseId, count, _language) {
|
|
172
|
+
if (count === 0) return []
|
|
173
|
+
if (_type === 'config') return [formatFriendlyId(_type)]
|
|
174
|
+
|
|
175
|
+
const counters = this.mongodb.getCollection(this.counterCollectionName)
|
|
176
|
+
const query = { _type }
|
|
177
|
+
if (_type !== 'course') {
|
|
178
|
+
query._courseId = parseObjectId(_courseId)
|
|
179
|
+
}
|
|
180
|
+
// Seed the counter from existing content on first use
|
|
181
|
+
const exists = await counters.findOne(query)
|
|
182
|
+
if (!exists) {
|
|
183
|
+
const maxSeq = await this.findMaxSeq(_type, _courseId)
|
|
184
|
+
await counters.updateOne(query, { $setOnInsert: { seq: maxSeq } }, { upsert: true })
|
|
185
|
+
}
|
|
186
|
+
// Atomically reserve a range of sequence numbers
|
|
187
|
+
const counter = await counters.findOneAndUpdate(
|
|
188
|
+
query,
|
|
189
|
+
{ $inc: { seq: count } },
|
|
190
|
+
{ returnDocument: 'after' }
|
|
191
|
+
)
|
|
192
|
+
const startSeq = counter.seq - count + 1
|
|
193
|
+
return Array.from({ length: count }, (_, i) => {
|
|
194
|
+
return formatFriendlyId(_type, startSeq + i, _language)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Finds the current max sequence number from existing content (for counter seeding)
|
|
200
|
+
* @param {String} _type
|
|
201
|
+
* @param {String} _courseId
|
|
202
|
+
* @return {Promise<Number>}
|
|
203
|
+
*/
|
|
204
|
+
async findMaxSeq (_type, _courseId) {
|
|
205
|
+
const collection = this.mongodb.getCollection(this.collectionName)
|
|
206
|
+
const query = { _type, _friendlyId: { $exists: true, $ne: '' } }
|
|
207
|
+
if (_type !== 'course') {
|
|
208
|
+
query._courseId = parseObjectId(_courseId)
|
|
209
|
+
}
|
|
210
|
+
const docs = await collection.find(query, { projection: { _friendlyId: 1 } }).toArray()
|
|
211
|
+
return parseMaxSeq(docs)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Removes counter documents for deleted courses
|
|
216
|
+
* @param {Array<String>} courseIds
|
|
217
|
+
* @return {Promise}
|
|
218
|
+
*/
|
|
219
|
+
async deleteCounters (courseIds) {
|
|
220
|
+
const counters = this.mongodb.getCollection(this.counterCollectionName)
|
|
221
|
+
const objectIds = courseIds.map(id => parseObjectId(id))
|
|
222
|
+
await counters.deleteMany({ _courseId: { $in: objectIds } })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Computes the _assetIds array for a content document
|
|
227
|
+
* @param {Object} doc Full content document
|
|
228
|
+
* @return {Promise<Array<String>>} Unique asset IDs found in the doc
|
|
229
|
+
*/
|
|
230
|
+
async computeAssetIds (doc) {
|
|
231
|
+
const schema = await this.getSchema(this.schemaName, doc)
|
|
232
|
+
return extractAssetIds(schema, doc)
|
|
100
233
|
}
|
|
101
234
|
|
|
102
235
|
/** @override */
|
|
103
236
|
async insert (data, options = {}, mongoOptions = {}) {
|
|
237
|
+
if (!data._friendlyId) {
|
|
238
|
+
const [id] = await this.generateFriendlyIds(data._type, data._courseId, 1, data._language)
|
|
239
|
+
data._friendlyId = id
|
|
240
|
+
}
|
|
241
|
+
if (!data._assetIds) {
|
|
242
|
+
data._assetIds = await this.computeAssetIds(data)
|
|
243
|
+
}
|
|
104
244
|
let doc
|
|
105
245
|
try {
|
|
106
246
|
doc = await super.insert(data, options, mongoOptions)
|
|
@@ -132,30 +272,55 @@ class ContentModule extends AbstractApiModule {
|
|
|
132
272
|
}
|
|
133
273
|
throw e
|
|
134
274
|
}
|
|
275
|
+
// Recompute _assetIds from the full merged document. Cast to ObjectId so
|
|
276
|
+
// the stored array matches the canonical insert-path format (and mongodb
|
|
277
|
+
// queries, which auto-convert 24-hex strings to ObjectId, can match it).
|
|
278
|
+
const newAssetIds = (await this.computeAssetIds(doc)).map(id => parseObjectId(id))
|
|
279
|
+
const oldAssetIds = doc._assetIds ?? []
|
|
280
|
+
if (newAssetIds.length !== oldAssetIds.length ||
|
|
281
|
+
!newAssetIds.every((id, i) => id.toString() === oldAssetIds[i]?.toString())) {
|
|
282
|
+
const collection = this.mongodb.getCollection(this.collectionName)
|
|
283
|
+
await collection.updateOne({ _id: doc._id }, { $set: { _assetIds: newAssetIds } })
|
|
284
|
+
doc._assetIds = newAssetIds
|
|
285
|
+
}
|
|
286
|
+
const sortChanged = '_sortOrder' in data || '_parentId' in data
|
|
287
|
+
const pluginsChanged = '_component' in data || '_menu' in data || '_theme' in data || '_enabledPlugins' in data
|
|
135
288
|
await Promise.all([
|
|
136
|
-
this.updateSortOrder(doc, data, options, mongoOptions),
|
|
137
|
-
this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {}, options, mongoOptions)
|
|
289
|
+
sortChanged && this.updateSortOrder(doc, data, options, mongoOptions),
|
|
290
|
+
pluginsChanged && this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {}, options, mongoOptions)
|
|
138
291
|
])
|
|
139
292
|
return doc
|
|
140
293
|
}
|
|
141
294
|
|
|
142
295
|
/** @override */
|
|
143
|
-
async delete (query, options, mongoOptions) {
|
|
296
|
+
async delete (query, options = {}, mongoOptions) {
|
|
144
297
|
this.setDefaultOptions(options)
|
|
145
298
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
299
|
+
const targetDoc = await this.findOne(query)
|
|
300
|
+
// @note super.find to avoid hooks etc. for performance purposes
|
|
301
|
+
const tree = new ContentTree(await super.find({ _courseId: targetDoc._courseId }, {}, { projection: { _id: 1, _parentId: 1, _type: 1, _component: 1, _enabledPlugins: 1, _menu: 1, _theme: 1 } }))
|
|
302
|
+
const descendants = tree.getDescendants(targetDoc._id)
|
|
303
|
+
if (targetDoc._type === 'course' && tree.config) {
|
|
304
|
+
descendants.push(tree.config)
|
|
150
305
|
}
|
|
151
|
-
const descendants = await getDescendants(q => this.find(q), targetDoc)
|
|
152
306
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
307
|
+
const deletedIds = new Set([targetDoc, ...descendants].map(d => d._id.toString()))
|
|
308
|
+
// bulk-delete descendants via raw mongodb to avoid per-item memory overhead and hook storms;
|
|
309
|
+
// postDeleteHook is invoked once below with the full descendants list
|
|
310
|
+
if (descendants.length > 0) {
|
|
311
|
+
const mongodb = await this.app.waitForModule('mongodb')
|
|
312
|
+
await mongodb.deleteMany(this.collectionName, { _id: { $in: descendants.map(d => d._id) } }, mongoOptions)
|
|
313
|
+
}
|
|
314
|
+
// delete target via super.delete to trigger deleteHook middleware (e.g. multilang)
|
|
315
|
+
await super.delete({ _id: targetDoc._id }, options, mongoOptions)
|
|
316
|
+
if (descendants.length > 0 && options.invokePostHook !== false) {
|
|
317
|
+
await this.postDeleteHook.invoke(descendants)
|
|
318
|
+
}
|
|
319
|
+
const remainingTree = new ContentTree(tree.items.filter(i => !deletedIds.has(i._id.toString())))
|
|
156
320
|
await Promise.all([
|
|
157
|
-
this.updateEnabledPlugins(targetDoc, {}, options, mongoOptions),
|
|
158
|
-
this.updateSortOrder(targetDoc, undefined, options, mongoOptions)
|
|
321
|
+
options.updateEnabledPlugins !== false && this.updateEnabledPlugins(targetDoc, { tree: remainingTree }, options, mongoOptions),
|
|
322
|
+
options.updateSortOrder !== false && this.updateSortOrder(targetDoc, undefined, options, mongoOptions),
|
|
323
|
+
targetDoc._type === 'course' && this.deleteCounters([targetDoc._courseId])
|
|
159
324
|
])
|
|
160
325
|
return [targetDoc, ...descendants]
|
|
161
326
|
}
|
|
@@ -181,14 +346,16 @@ class ContentModule extends AbstractApiModule {
|
|
|
181
346
|
}
|
|
182
347
|
const newItems = []
|
|
183
348
|
let parent
|
|
349
|
+
let parentIsNew = false
|
|
184
350
|
try {
|
|
185
351
|
// figure out which children need creating
|
|
186
352
|
if (rootId === undefined) { // new course
|
|
187
353
|
parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
|
|
188
354
|
newItems.push(parent)
|
|
189
355
|
childTypes.splice(0, 1, 'config')
|
|
356
|
+
parentIsNew = true
|
|
190
357
|
} else {
|
|
191
|
-
parent =
|
|
358
|
+
parent = await this.findOne({ _id: rootId })
|
|
192
359
|
// special case for menus
|
|
193
360
|
req.body?._type === 'menu'
|
|
194
361
|
? childTypes.splice(0, 1, 'menu')
|
|
@@ -202,68 +369,179 @@ class ContentModule extends AbstractApiModule {
|
|
|
202
369
|
_courseId: parent._courseId.toString()
|
|
203
370
|
})
|
|
204
371
|
}
|
|
205
|
-
|
|
372
|
+
// Inner items are the first child of a parent we just created, so
|
|
373
|
+
// _sortOrder is always 1. For the first iteration against an existing
|
|
374
|
+
// parent we leave _sortOrder unset — updateSortOrder(topItem) places it.
|
|
375
|
+
if (parentIsNew && _type !== 'config') data._sortOrder = 1
|
|
376
|
+
const item = await this.insert(data, { updateSortOrder: false, updateEnabledPlugins: false })
|
|
206
377
|
newItems.push(item)
|
|
207
|
-
if (_type !== 'config')
|
|
378
|
+
if (_type !== 'config') {
|
|
379
|
+
parent = item
|
|
380
|
+
parentIsNew = true
|
|
381
|
+
}
|
|
208
382
|
}
|
|
209
383
|
} catch (e) {
|
|
210
384
|
await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
|
|
211
385
|
throw e
|
|
212
386
|
}
|
|
213
|
-
//
|
|
214
|
-
|
|
387
|
+
// run side effects once for the topmost new item
|
|
388
|
+
const topItem = newItems[0]
|
|
389
|
+
await Promise.all([
|
|
390
|
+
this.updateSortOrder(topItem, topItem),
|
|
391
|
+
this.updateEnabledPlugins(topItem, { forceUpdate: true })
|
|
392
|
+
])
|
|
393
|
+
return topItem
|
|
215
394
|
}
|
|
216
395
|
|
|
217
396
|
/**
|
|
218
|
-
*
|
|
397
|
+
* Clones a content item and all its descendants in a single bulk operation.
|
|
398
|
+
* Pre-generates all _id values and friendly IDs, then inserts everything in parallel.
|
|
219
399
|
* @param {String} userId The user performing the action
|
|
220
400
|
* @param {String} _id ID of the object to clone
|
|
221
401
|
* @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
|
|
222
|
-
* @param {Object} customData Data to be applied to the content item
|
|
223
|
-
* @
|
|
402
|
+
* @param {Object} customData Data to be applied to the root content item
|
|
403
|
+
* @param {Object} options
|
|
404
|
+
* @param {ContentTree} options.tree Pre-built tree to avoid a DB query
|
|
405
|
+
* @param {Object} options.parent Pre-fetched parent doc to avoid redundant lookup
|
|
406
|
+
* @return {Promise<Object>} The cloned root item
|
|
224
407
|
*/
|
|
225
408
|
async clone (userId, _id, _parentId, customData = {}, options = {}) {
|
|
226
|
-
|
|
409
|
+
let { tree, parent } = options
|
|
410
|
+
|
|
411
|
+
const originalDoc = tree
|
|
412
|
+
? tree.getById(_id)
|
|
413
|
+
: await this.findOne({ _id })
|
|
227
414
|
if (!originalDoc) {
|
|
228
415
|
throw this.app.errors.NOT_FOUND
|
|
229
|
-
.setData({ type:
|
|
416
|
+
.setData({ type: 'content', id: _id })
|
|
230
417
|
}
|
|
231
|
-
if (options.invokePreHook !== false) await this.preCloneHook.invoke(originalDoc)
|
|
232
|
-
|
|
233
|
-
const [parent] = _parentId ? await this.find({ _id: _parentId }) : []
|
|
234
418
|
|
|
419
|
+
if (!parent && _parentId) {
|
|
420
|
+
parent = await this.findOne({ _id: _parentId }, { throwOnMissing: false }, { projection: { _id: 1, _type: 1, _courseId: 1 } })
|
|
421
|
+
}
|
|
235
422
|
if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
|
|
236
423
|
throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
|
|
237
424
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
425
|
+
if (!tree) {
|
|
426
|
+
const sourceItems = await this.mongodb.find(this.collectionName, { _courseId: originalDoc._courseId })
|
|
427
|
+
tree = new ContentTree(sourceItems)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Collect all items to clone: root, config (if course clone), then all descendants
|
|
431
|
+
const allItems = [originalDoc]
|
|
432
|
+
if (originalDoc._type === 'course' && tree.config) {
|
|
433
|
+
allItems.push(tree.config)
|
|
434
|
+
}
|
|
435
|
+
allItems.push(...tree.getDescendants(_id))
|
|
436
|
+
|
|
437
|
+
if (options.invokePreHook !== false) {
|
|
438
|
+
for (const item of allItems) await this.preCloneHook.invoke(item)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Pre-generate ObjectIds for every item (old _id → new _id)
|
|
442
|
+
const idMap = new Map()
|
|
443
|
+
for (const item of allItems) {
|
|
444
|
+
idMap.set(item._id.toString(), createObjectId())
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const newCourseId = originalDoc._type === 'course'
|
|
448
|
+
? idMap.get(originalDoc._id.toString()).toString()
|
|
449
|
+
: (parent?._type === 'course' ? parent._id.toString() : parent._courseId.toString())
|
|
450
|
+
|
|
451
|
+
// Pre-allocate friendly IDs in bulk per type
|
|
452
|
+
const typeCounts = new Map()
|
|
453
|
+
for (const item of allItems) {
|
|
454
|
+
if (item._type === 'course' || item._type === 'config') continue
|
|
455
|
+
typeCounts.set(item._type, (typeCounts.get(item._type) ?? 0) + 1)
|
|
456
|
+
}
|
|
457
|
+
// type → { ids, next } — bundle the cursor with the array so the payload loop is O(n) (Array#shift would be O(n²))
|
|
458
|
+
const friendlyIds = new Map()
|
|
459
|
+
await Promise.all([...typeCounts].map(async ([_type, count]) => {
|
|
460
|
+
friendlyIds.set(_type, { ids: await this.generateFriendlyIds(_type, newCourseId, count), next: 0 })
|
|
461
|
+
}))
|
|
462
|
+
|
|
463
|
+
// Pre-allocate sequential _trackingId for cloned blocks. Bulk insertMany
|
|
464
|
+
// defeats SpoorTrackingModule's preInsertHook (which reads the current max
|
|
465
|
+
// from the DB per-block), so without this every cloned block would get the
|
|
466
|
+
// same id.
|
|
467
|
+
const blockCount = typeCounts.get('block') ?? 0
|
|
468
|
+
let nextTrackingId
|
|
469
|
+
if (blockCount > 0) {
|
|
470
|
+
const [{ _trackingId: maxTrackingId = 0 } = {}] = await this.find(
|
|
471
|
+
{ _courseId: newCourseId }, {}, { limit: 1, sort: [['_trackingId', -1]] }
|
|
472
|
+
)
|
|
473
|
+
nextTrackingId = maxTrackingId + 1
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Build all insert payloads with pre-mapped IDs and parent references
|
|
477
|
+
const rootId = _id.toString()
|
|
478
|
+
const payloads = allItems.map(item => {
|
|
479
|
+
const oldId = item._id.toString()
|
|
480
|
+
const newId = idMap.get(oldId)
|
|
481
|
+
const isCourse = item._type === 'course'
|
|
482
|
+
const isConfig = item._type === 'config'
|
|
483
|
+
|
|
484
|
+
let newParentId
|
|
485
|
+
if (oldId === rootId) newParentId = _parentId
|
|
486
|
+
else if (isConfig) newParentId = undefined
|
|
487
|
+
else newParentId = idMap.get(item._parentId?.toString())?.toString()
|
|
488
|
+
|
|
489
|
+
let friendlyId
|
|
490
|
+
if (isCourse) friendlyId = item._friendlyId
|
|
491
|
+
else if (isConfig) friendlyId = formatFriendlyId('config')
|
|
492
|
+
else {
|
|
493
|
+
const queue = friendlyIds.get(item._type)
|
|
494
|
+
friendlyId = queue?.ids[queue.next++]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return stringifyValues({
|
|
498
|
+
...item,
|
|
499
|
+
_id: newId,
|
|
500
|
+
_trackingId: item._type === 'block' ? nextTrackingId++ : undefined,
|
|
501
|
+
_friendlyId: friendlyId,
|
|
502
|
+
_courseId: isCourse ? newId.toString() : newCourseId,
|
|
503
|
+
_parentId: newParentId,
|
|
504
|
+
createdBy: userId,
|
|
505
|
+
...(oldId === rootId ? customData : {})
|
|
506
|
+
})
|
|
248
507
|
})
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
508
|
+
|
|
509
|
+
// Fire preInsertHook on each payload (allows observer modules to set timestamps etc.)
|
|
510
|
+
await Promise.all(payloads.map(payload =>
|
|
511
|
+
this.preInsertHook.invoke(payload, { schemaName: contentTypeToSchemaName(payload._type), collectionName: this.collectionName }, {})
|
|
512
|
+
))
|
|
513
|
+
|
|
514
|
+
// Convert known ID fields to ObjectId instances and bulk insert in a single round-trip
|
|
515
|
+
const allNewIds = allItems.map(item => idMap.get(item._id.toString()))
|
|
516
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
517
|
+
const payload = payloads[i]
|
|
518
|
+
payload._id = allNewIds[i]
|
|
519
|
+
if (payload._courseId) payload._courseId = new ObjectId(payload._courseId)
|
|
520
|
+
if (payload._parentId) payload._parentId = new ObjectId(payload._parentId)
|
|
521
|
+
if (payload.createdBy) payload.createdBy = new ObjectId(payload.createdBy)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const collection = this.mongodb.getCollection(this.collectionName)
|
|
525
|
+
try {
|
|
526
|
+
await collection.insertMany(payloads, { ordered: false })
|
|
527
|
+
} catch (e) {
|
|
528
|
+
await collection.deleteMany({ _id: { $in: allNewIds } }).catch(() => {})
|
|
529
|
+
throw e
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// payloads (post-convertObjectIds) are the stored documents — no find-back needed
|
|
533
|
+
await Promise.all(payloads.map(doc => this.postInsertHook.invoke(doc)))
|
|
534
|
+
|
|
535
|
+
if (options.invokePostHook !== false) {
|
|
536
|
+
for (let i = 0; i < allItems.length; i++) {
|
|
537
|
+
await this.postCloneHook.invoke(allItems[i], payloads[i])
|
|
258
538
|
}
|
|
259
539
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
await this.clone(userId, children[i]._id, newData._id)
|
|
540
|
+
if (originalDoc._courseId?.toString() !== payloads[0]._courseId?.toString()) {
|
|
541
|
+
await this.updateEnabledPlugins(payloads[0])
|
|
263
542
|
}
|
|
264
|
-
if (options.invokePostHook !== false) await this.postCloneHook.invoke(originalDoc, newData)
|
|
265
543
|
|
|
266
|
-
return
|
|
544
|
+
return payloads[0]
|
|
267
545
|
}
|
|
268
546
|
|
|
269
547
|
/**
|
|
@@ -277,15 +555,12 @@ class ContentModule extends AbstractApiModule {
|
|
|
277
555
|
if (item._type === 'config' || item._type === 'course' || !item._parentId) {
|
|
278
556
|
return
|
|
279
557
|
}
|
|
280
|
-
const siblings = await
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
558
|
+
const siblings = await super.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 }, projection: { _id: 1, _sortOrder: 1 } })
|
|
559
|
+
const ops = computeSortOrderOps(siblings, updateData ? item : undefined)
|
|
560
|
+
if (ops.length > 0) {
|
|
561
|
+
const collection = this.mongodb.getCollection(this.collectionName)
|
|
562
|
+
return collection.bulkWrite(ops, { ordered: false })
|
|
284
563
|
}
|
|
285
|
-
return Promise.all(siblings.map(async (s, i) => {
|
|
286
|
-
const _sortOrder = i + 1
|
|
287
|
-
if (s._sortOrder !== _sortOrder) return super.update({ _id: s._id }, { _sortOrder }, parentOptions, parentMongoOptions)
|
|
288
|
-
}))
|
|
289
564
|
}
|
|
290
565
|
|
|
291
566
|
/**
|
|
@@ -293,49 +568,93 @@ class ContentModule extends AbstractApiModule {
|
|
|
293
568
|
* @param {Object} item The updated item
|
|
294
569
|
* @param {Object} options
|
|
295
570
|
* @param {Boolean} options.forceUpdate Forces an update of defaults regardless of whether the _enabledPlugins list has changed
|
|
571
|
+
* @param {ContentTree} options.tree Pre-built tree to avoid redundant full-course fetch
|
|
296
572
|
* @return {Promise}
|
|
297
573
|
*/
|
|
298
|
-
async updateEnabledPlugins ({ _courseId }, options = {}, parentOptions, parentMongoOptions) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
574
|
+
async updateEnabledPlugins ({ _courseId, _type }, options = {}, parentOptions, parentMongoOptions) {
|
|
575
|
+
// skip types that can never affect the plugin list (e.g. page, article).
|
|
576
|
+
if (options.forceUpdate !== true && _type && _type !== 'component' && _type !== 'config') {
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
const { contentplugin, jsonschema } = this
|
|
580
|
+
const tree = options.tree ?? new ContentTree(await super.find({ _courseId }, {}, { projection: { _id: 1, _type: 1, _component: 1, _enabledPlugins: 1, _menu: 1, _theme: 1 } }))
|
|
581
|
+
const config = tree.config
|
|
302
582
|
|
|
303
583
|
if (!config) {
|
|
304
584
|
return // can't continue if there's no config to update
|
|
305
585
|
}
|
|
306
|
-
const
|
|
307
|
-
const
|
|
586
|
+
const currentPlugins = new Set(config._enabledPlugins ?? [])
|
|
587
|
+
const extensionNames = (await contentplugin.find({ type: 'extension' }, {}, { projection: { _id: 0, name: 1 } })).map(p => p.name)
|
|
588
|
+
const componentNames = tree.getComponentNames()
|
|
308
589
|
// generate unique list of used plugins
|
|
309
|
-
const
|
|
310
|
-
...
|
|
590
|
+
const nextPlugins = new Set([
|
|
591
|
+
...[...currentPlugins].filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
|
|
311
592
|
...componentNames,
|
|
312
593
|
config._menu,
|
|
313
594
|
config._theme
|
|
314
|
-
]))
|
|
595
|
+
].filter(Boolean))
|
|
315
596
|
if (options.forceUpdate !== true &&
|
|
316
|
-
|
|
317
|
-
|
|
597
|
+
currentPlugins.size === nextPlugins.size &&
|
|
598
|
+
[...currentPlugins].every(p => nextPlugins.has(p))) {
|
|
318
599
|
return // return early if the lists already match
|
|
319
600
|
}
|
|
320
|
-
// generate list of
|
|
321
|
-
const
|
|
322
|
-
.filter(p => options.forceUpdate || !
|
|
323
|
-
.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
601
|
+
// generate list of content types that need defaults applied for newly added plugins
|
|
602
|
+
const newPluginSchemas = [...nextPlugins]
|
|
603
|
+
.filter(p => options.forceUpdate || !currentPlugins.has(p))
|
|
604
|
+
.flatMap(p => contentplugin.getPluginSchemas(p))
|
|
605
|
+
|
|
606
|
+
const affectedTypes = new Set()
|
|
607
|
+
for (const schemaName of newPluginSchemas) {
|
|
608
|
+
const rawSchema = jsonschema.schemas[schemaName]?.raw
|
|
609
|
+
const ref = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
|
|
610
|
+
for (const t of (ref === 'contentobject' ? ['menu', 'page'] : [ref])) {
|
|
611
|
+
if (t) affectedTypes.add(t)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const _enabledPlugins = [...nextPlugins]
|
|
332
615
|
// update config._enabledPlugins
|
|
333
616
|
await super.update({ _courseId, _type: 'config' }, { _enabledPlugins }, parentOptions, parentMongoOptions)
|
|
334
|
-
// update
|
|
335
|
-
//
|
|
336
|
-
if (
|
|
337
|
-
const toUpdate = await super.find({ _courseId, _type: { $in:
|
|
338
|
-
return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {}, parentOptions, parentMongoOptions)))
|
|
617
|
+
// empty update re-validates to apply new defaults; ignoreRequired because some plugins
|
|
618
|
+
// declare top-level required properties with no default (e.g. adapt-contrib-glossary)
|
|
619
|
+
if (affectedTypes.size > 0) {
|
|
620
|
+
const toUpdate = await super.find({ _courseId, _type: { $in: [...affectedTypes] } }, {}, { projection: { _id: 1 } })
|
|
621
|
+
return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {}, { ...parentOptions, ignoreRequired: true }, parentMongoOptions)))
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Returns a lightweight projection of all content items for a course
|
|
627
|
+
* @param {external:ExpressRequest} req
|
|
628
|
+
* @param {external:ExpressResponse} res
|
|
629
|
+
* @param {Function} next
|
|
630
|
+
*/
|
|
631
|
+
async handleTree (req, res, next) {
|
|
632
|
+
try {
|
|
633
|
+
const _courseId = req.apiData.query._courseId
|
|
634
|
+
const course = await this.findOne(
|
|
635
|
+
{ _type: 'course', _courseId },
|
|
636
|
+
{ validate: false },
|
|
637
|
+
{ projection: { updatedAt: 1 } }
|
|
638
|
+
)
|
|
639
|
+
const lastModified = new Date(course.updatedAt)
|
|
640
|
+
lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
|
|
641
|
+
const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
|
|
642
|
+
if (ifModifiedSince && lastModified <= ifModifiedSince) {
|
|
643
|
+
return res.status(304).end()
|
|
644
|
+
}
|
|
645
|
+
const items = await this.find(
|
|
646
|
+
{ _courseId },
|
|
647
|
+
{ validate: false },
|
|
648
|
+
{ projection: { _id: 1, _parentId: 1, _courseId: 1, _type: 1, _sortOrder: 1, title: 1, displayTitle: 1, _friendlyId: 1, _component: 1, _layout: 1, _menu: 1, _theme: 1, _enabledPlugins: 1, updatedAt: 1 } }
|
|
649
|
+
)
|
|
650
|
+
const tree = new ContentTree(items)
|
|
651
|
+
res.set('Last-Modified', lastModified.toUTCString())
|
|
652
|
+
res.json(items.map(item => ({
|
|
653
|
+
...item,
|
|
654
|
+
_children: tree.getChildren(item._id).map(c => c._id)
|
|
655
|
+
})))
|
|
656
|
+
} catch (e) {
|
|
657
|
+
return next(e)
|
|
339
658
|
}
|
|
340
659
|
}
|
|
341
660
|
|
|
@@ -364,7 +683,10 @@ class ContentModule extends AbstractApiModule {
|
|
|
364
683
|
try {
|
|
365
684
|
await this.requestHook.invoke(req)
|
|
366
685
|
const { _id, _parentId } = req.body
|
|
367
|
-
|
|
686
|
+
if (!_id) {
|
|
687
|
+
throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _id })
|
|
688
|
+
}
|
|
689
|
+
const source = await this.findOne({ _id })
|
|
368
690
|
await this.checkAccess(req, source)
|
|
369
691
|
|
|
370
692
|
const customData = { ...req.body }
|