adapt-authoring-content 2.1.8 → 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 +423 -108
- 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,25 +272,41 @@ 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
|
-
|
|
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
|
|
154
310
|
if (descendants.length > 0) {
|
|
155
311
|
const mongodb = await this.app.waitForModule('mongodb')
|
|
156
312
|
await mongodb.deleteMany(this.collectionName, { _id: { $in: descendants.map(d => d._id) } }, mongoOptions)
|
|
@@ -160,9 +316,11 @@ class ContentModule extends AbstractApiModule {
|
|
|
160
316
|
if (descendants.length > 0 && options.invokePostHook !== false) {
|
|
161
317
|
await this.postDeleteHook.invoke(descendants)
|
|
162
318
|
}
|
|
319
|
+
const remainingTree = new ContentTree(tree.items.filter(i => !deletedIds.has(i._id.toString())))
|
|
163
320
|
await Promise.all([
|
|
164
|
-
this.updateEnabledPlugins(targetDoc, {}, options, mongoOptions),
|
|
165
|
-
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])
|
|
166
324
|
])
|
|
167
325
|
return [targetDoc, ...descendants]
|
|
168
326
|
}
|
|
@@ -188,14 +346,16 @@ class ContentModule extends AbstractApiModule {
|
|
|
188
346
|
}
|
|
189
347
|
const newItems = []
|
|
190
348
|
let parent
|
|
349
|
+
let parentIsNew = false
|
|
191
350
|
try {
|
|
192
351
|
// figure out which children need creating
|
|
193
352
|
if (rootId === undefined) { // new course
|
|
194
353
|
parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
|
|
195
354
|
newItems.push(parent)
|
|
196
355
|
childTypes.splice(0, 1, 'config')
|
|
356
|
+
parentIsNew = true
|
|
197
357
|
} else {
|
|
198
|
-
parent =
|
|
358
|
+
parent = await this.findOne({ _id: rootId })
|
|
199
359
|
// special case for menus
|
|
200
360
|
req.body?._type === 'menu'
|
|
201
361
|
? childTypes.splice(0, 1, 'menu')
|
|
@@ -209,68 +369,179 @@ class ContentModule extends AbstractApiModule {
|
|
|
209
369
|
_courseId: parent._courseId.toString()
|
|
210
370
|
})
|
|
211
371
|
}
|
|
212
|
-
|
|
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 })
|
|
213
377
|
newItems.push(item)
|
|
214
|
-
if (_type !== 'config')
|
|
378
|
+
if (_type !== 'config') {
|
|
379
|
+
parent = item
|
|
380
|
+
parentIsNew = true
|
|
381
|
+
}
|
|
215
382
|
}
|
|
216
383
|
} catch (e) {
|
|
217
384
|
await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
|
|
218
385
|
throw e
|
|
219
386
|
}
|
|
220
|
-
//
|
|
221
|
-
|
|
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
|
|
222
394
|
}
|
|
223
395
|
|
|
224
396
|
/**
|
|
225
|
-
*
|
|
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.
|
|
226
399
|
* @param {String} userId The user performing the action
|
|
227
400
|
* @param {String} _id ID of the object to clone
|
|
228
401
|
* @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
|
|
229
|
-
* @param {Object} customData Data to be applied to the content item
|
|
230
|
-
* @
|
|
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
|
|
231
407
|
*/
|
|
232
408
|
async clone (userId, _id, _parentId, customData = {}, options = {}) {
|
|
233
|
-
|
|
409
|
+
let { tree, parent } = options
|
|
410
|
+
|
|
411
|
+
const originalDoc = tree
|
|
412
|
+
? tree.getById(_id)
|
|
413
|
+
: await this.findOne({ _id })
|
|
234
414
|
if (!originalDoc) {
|
|
235
415
|
throw this.app.errors.NOT_FOUND
|
|
236
|
-
.setData({ type:
|
|
416
|
+
.setData({ type: 'content', id: _id })
|
|
237
417
|
}
|
|
238
|
-
if (options.invokePreHook !== false) await this.preCloneHook.invoke(originalDoc)
|
|
239
|
-
|
|
240
|
-
const [parent] = _parentId ? await this.find({ _id: _parentId }) : []
|
|
241
418
|
|
|
419
|
+
if (!parent && _parentId) {
|
|
420
|
+
parent = await this.findOne({ _id: _parentId }, { throwOnMissing: false }, { projection: { _id: 1, _type: 1, _courseId: 1 } })
|
|
421
|
+
}
|
|
242
422
|
if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
|
|
243
423
|
throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
|
|
244
424
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
})
|
|
255
507
|
})
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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])
|
|
265
538
|
}
|
|
266
539
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
await this.clone(userId, children[i]._id, newData._id)
|
|
540
|
+
if (originalDoc._courseId?.toString() !== payloads[0]._courseId?.toString()) {
|
|
541
|
+
await this.updateEnabledPlugins(payloads[0])
|
|
270
542
|
}
|
|
271
|
-
if (options.invokePostHook !== false) await this.postCloneHook.invoke(originalDoc, newData)
|
|
272
543
|
|
|
273
|
-
return
|
|
544
|
+
return payloads[0]
|
|
274
545
|
}
|
|
275
546
|
|
|
276
547
|
/**
|
|
@@ -284,15 +555,12 @@ class ContentModule extends AbstractApiModule {
|
|
|
284
555
|
if (item._type === 'config' || item._type === 'course' || !item._parentId) {
|
|
285
556
|
return
|
|
286
557
|
}
|
|
287
|
-
const siblings = await
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 })
|
|
291
563
|
}
|
|
292
|
-
return Promise.all(siblings.map(async (s, i) => {
|
|
293
|
-
const _sortOrder = i + 1
|
|
294
|
-
if (s._sortOrder !== _sortOrder) return super.update({ _id: s._id }, { _sortOrder }, parentOptions, parentMongoOptions)
|
|
295
|
-
}))
|
|
296
564
|
}
|
|
297
565
|
|
|
298
566
|
/**
|
|
@@ -300,49 +568,93 @@ class ContentModule extends AbstractApiModule {
|
|
|
300
568
|
* @param {Object} item The updated item
|
|
301
569
|
* @param {Object} options
|
|
302
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
|
|
303
572
|
* @return {Promise}
|
|
304
573
|
*/
|
|
305
|
-
async updateEnabledPlugins ({ _courseId }, options = {}, parentOptions, parentMongoOptions) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
309
582
|
|
|
310
583
|
if (!config) {
|
|
311
584
|
return // can't continue if there's no config to update
|
|
312
585
|
}
|
|
313
|
-
const
|
|
314
|
-
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()
|
|
315
589
|
// generate unique list of used plugins
|
|
316
|
-
const
|
|
317
|
-
...
|
|
590
|
+
const nextPlugins = new Set([
|
|
591
|
+
...[...currentPlugins].filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
|
|
318
592
|
...componentNames,
|
|
319
593
|
config._menu,
|
|
320
594
|
config._theme
|
|
321
|
-
]))
|
|
595
|
+
].filter(Boolean))
|
|
322
596
|
if (options.forceUpdate !== true &&
|
|
323
|
-
|
|
324
|
-
|
|
597
|
+
currentPlugins.size === nextPlugins.size &&
|
|
598
|
+
[...currentPlugins].every(p => nextPlugins.has(p))) {
|
|
325
599
|
return // return early if the lists already match
|
|
326
600
|
}
|
|
327
|
-
// generate list of
|
|
328
|
-
const
|
|
329
|
-
.filter(p => options.forceUpdate || !
|
|
330
|
-
.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
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]
|
|
339
615
|
// update config._enabledPlugins
|
|
340
616
|
await super.update({ _courseId, _type: 'config' }, { _enabledPlugins }, parentOptions, parentMongoOptions)
|
|
341
|
-
// update
|
|
342
|
-
//
|
|
343
|
-
if (
|
|
344
|
-
const toUpdate = await super.find({ _courseId, _type: { $in:
|
|
345
|
-
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)
|
|
346
658
|
}
|
|
347
659
|
}
|
|
348
660
|
|
|
@@ -371,7 +683,10 @@ class ContentModule extends AbstractApiModule {
|
|
|
371
683
|
try {
|
|
372
684
|
await this.requestHook.invoke(req)
|
|
373
685
|
const { _id, _parentId } = req.body
|
|
374
|
-
|
|
686
|
+
if (!_id) {
|
|
687
|
+
throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _id })
|
|
688
|
+
}
|
|
689
|
+
const source = await this.findOne({ _id })
|
|
375
690
|
await this.checkAccess(req, source)
|
|
376
691
|
|
|
377
692
|
const customData = { ...req.body }
|