adapt-authoring-content 3.0.6 → 3.2.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 -0
- package/lib/ContentModule.js +46 -1
- package/lib/ContentTree.js +14 -0
- package/lib/utils/buildAssetUsagePipeline.js +29 -0
- package/lib/utils.js +1 -0
- package/package.json +1 -1
- package/routes.json +23 -0
- package/tests/ContentTree.spec.js +49 -0
- package/tests/utils-buildAssetUsagePipeline.spec.js +48 -0
package/errors/errors.json
CHANGED
|
@@ -21,5 +21,12 @@
|
|
|
21
21
|
},
|
|
22
22
|
"description": "Resource is currently being used in courses",
|
|
23
23
|
"statusCode": 400
|
|
24
|
+
},
|
|
25
|
+
"EMPTY_CONTAINERS": {
|
|
26
|
+
"data": {
|
|
27
|
+
"items": "The childless content items blocking the build"
|
|
28
|
+
},
|
|
29
|
+
"description": "Course cannot be built: one or more pages, articles or blocks have no content",
|
|
30
|
+
"statusCode": 400
|
|
24
31
|
}
|
|
25
32
|
}
|
package/lib/ContentModule.js
CHANGED
|
@@ -2,7 +2,7 @@ import { AbstractApiModule } from 'adapt-authoring-api'
|
|
|
2
2
|
import { Hook, stringifyValues } from 'adapt-authoring-core'
|
|
3
3
|
import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
|
|
4
4
|
import { ObjectId } from 'mongodb'
|
|
5
|
-
import { ContentTree, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
|
|
5
|
+
import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
|
|
6
6
|
/**
|
|
7
7
|
* Module which handles course content
|
|
8
8
|
* @memberof content
|
|
@@ -59,6 +59,12 @@ class ContentModule extends AbstractApiModule {
|
|
|
59
59
|
|
|
60
60
|
assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))
|
|
61
61
|
|
|
62
|
+
// block builds when the course structure has empty containers. adaptframework depends on
|
|
63
|
+
// content (not vice-versa), so tap its hook once available rather than awaiting it here.
|
|
64
|
+
this.app.waitForModule('adaptframework').then(framework => {
|
|
65
|
+
framework.preBuildHook.tap(this.enforceNoEmptyContainers.bind(this))
|
|
66
|
+
})
|
|
67
|
+
|
|
62
68
|
await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
|
|
63
69
|
await mongodb.setIndex(this.collectionName, { _parentId: 1 })
|
|
64
70
|
await mongodb.setIndex(this.collectionName, { _type: 1, _courseId: 1 })
|
|
@@ -104,6 +110,45 @@ class ContentModule extends AbstractApiModule {
|
|
|
104
110
|
throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* preBuildHook observer: refuses a build when any non-component content item has no children
|
|
115
|
+
* (an empty page, article or block). Components are leaf nodes and config is exempt.
|
|
116
|
+
* @param {AdaptFrameworkBuild} build The build being run
|
|
117
|
+
* @return {Promise}
|
|
118
|
+
*/
|
|
119
|
+
async enforceNoEmptyContainers (build) {
|
|
120
|
+
const _courseId = parseObjectId(build.courseId)
|
|
121
|
+
const items = await this.find(
|
|
122
|
+
{ $or: [{ _id: _courseId }, { _courseId }] },
|
|
123
|
+
{ validate: false },
|
|
124
|
+
{ projection: { _type: 1, _parentId: 1, title: 1, displayTitle: 1 } }
|
|
125
|
+
)
|
|
126
|
+
const empty = new ContentTree(items).getEmptyContainers()
|
|
127
|
+
if (!empty.length) return
|
|
128
|
+
throw this.app.errors.EMPTY_CONTAINERS.setData({
|
|
129
|
+
items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title }))
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Returns a map of asset _id to the number of distinct courses each asset is referenced by.
|
|
135
|
+
* Reads the indexed `_assetIds` field. Accepts an optional `assetIds` array in the request body to
|
|
136
|
+
* scope the counts (e.g. the page of assets shown in the UI); assets with no usage are omitted.
|
|
137
|
+
* @param {external:ExpressRequest} req
|
|
138
|
+
* @param {external:ExpressResponse} res
|
|
139
|
+
* @param {function} next
|
|
140
|
+
* @return {Promise}
|
|
141
|
+
*/
|
|
142
|
+
async handleAssetUsage (req, res, next) {
|
|
143
|
+
try {
|
|
144
|
+
const assetIds = Array.isArray(req.body?.assetIds) ? req.body.assetIds.map(id => parseObjectId(id)) : undefined
|
|
145
|
+
const results = await this.mongodb.getCollection(this.collectionName).aggregate(buildAssetUsagePipeline(assetIds)).toArray()
|
|
146
|
+
res.json(Object.fromEntries(results.map(r => [r._id.toString(), r.courseCount])))
|
|
147
|
+
} catch (e) {
|
|
148
|
+
next(e)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
/** @override */
|
|
108
153
|
async getSchemaName (data) {
|
|
109
154
|
const { contentplugin } = this
|
package/lib/ContentTree.js
CHANGED
|
@@ -114,6 +114,20 @@ class ContentTree {
|
|
|
114
114
|
return this.getChildren(item._parentId).filter(c => c._id.toString() !== id)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Finds container items that have no children. Components are leaf nodes and
|
|
119
|
+
* config is a childless root — both are exempt; every other type (course, menu,
|
|
120
|
+
* page, article, block) is expected to contain at least one child.
|
|
121
|
+
* @returns {Array<Object>} Childless container items
|
|
122
|
+
*/
|
|
123
|
+
getEmptyContainers () {
|
|
124
|
+
return this.items.filter(i =>
|
|
125
|
+
i._type !== 'component' &&
|
|
126
|
+
i._type !== 'config' &&
|
|
127
|
+
this.getChildren(i._id).length === 0
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
/**
|
|
118
132
|
* O(1) — unique component names across the course
|
|
119
133
|
* @returns {Array<string>}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the aggregation pipeline that counts, per asset, how many distinct courses reference it.
|
|
3
|
+
*
|
|
4
|
+
* Operates on the content collection's indexed `_assetIds` field (maintained on every content
|
|
5
|
+
* insert/update). The leading `$match` lets the query use the `_assetIds` index when scoped; the
|
|
6
|
+
* post-`$unwind` `$match` discards the other asset ids carried by matched documents so only the
|
|
7
|
+
* requested assets remain. Counting distinct `_courseId` via `$addToSet` (rather than documents)
|
|
8
|
+
* means an asset referenced by many content items within one course still counts as one course.
|
|
9
|
+
*
|
|
10
|
+
* Pure helper extracted from {@link ContentModule#handleAssetUsage} so it can be unit-tested without
|
|
11
|
+
* booting the app. Asset ids must already be coerced to ObjectId — `getCollection().aggregate()` is
|
|
12
|
+
* the raw driver and does not normalise ObjectId strings the way the module query layer does.
|
|
13
|
+
*
|
|
14
|
+
* @param {Array} [assetIds] Asset ObjectIds to scope the counts to. Omit/empty to count all assets.
|
|
15
|
+
* @returns {Array<Object>} Aggregation pipeline producing `{ _id: <assetId>, courseCount }` rows
|
|
16
|
+
* @memberof content
|
|
17
|
+
*/
|
|
18
|
+
export default function buildAssetUsagePipeline (assetIds) {
|
|
19
|
+
const match = Array.isArray(assetIds) && assetIds.length > 0
|
|
20
|
+
? { $match: { _assetIds: { $in: assetIds } } }
|
|
21
|
+
: null
|
|
22
|
+
return [
|
|
23
|
+
...(match ? [match] : []),
|
|
24
|
+
{ $unwind: '$_assetIds' },
|
|
25
|
+
...(match ? [match] : []),
|
|
26
|
+
{ $group: { _id: '$_assetIds', courses: { $addToSet: '$_courseId' } } },
|
|
27
|
+
{ $project: { courseCount: { $size: '$courses' } } }
|
|
28
|
+
]
|
|
29
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as ContentTree } from './ContentTree.js'
|
|
2
|
+
export { default as buildAssetUsagePipeline } from './utils/buildAssetUsagePipeline.js'
|
|
2
3
|
export { default as computeSortOrderOps } from './utils/computeSortOrderOps.js'
|
|
3
4
|
export { extractAssetIds } from './utils/extractAssetIds.js'
|
|
4
5
|
export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaName.js'
|
package/package.json
CHANGED
package/routes.json
CHANGED
|
@@ -110,6 +110,29 @@
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"route": "/assetusage",
|
|
116
|
+
"handlers": { "post": "handleAssetUsage" },
|
|
117
|
+
"permissions": { "post": ["read:${scope}"] },
|
|
118
|
+
"meta": {
|
|
119
|
+
"post": {
|
|
120
|
+
"summary": "Count how many distinct courses each asset is used in",
|
|
121
|
+
"requestBody": {
|
|
122
|
+
"content": {
|
|
123
|
+
"application/json": {
|
|
124
|
+
"schema": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"assetIds": { "type": "array", "items": { "type": "string" }, "description": "Optional list of asset _ids to scope the counts to. Omit to count all assets." }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"responses": { "200": { "description": "Object mapping asset _id to the number of distinct courses referencing it; assets with no usage are omitted" } }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
113
136
|
}
|
|
114
137
|
]
|
|
115
138
|
}
|
|
@@ -227,4 +227,53 @@ describe('ContentTree', () => {
|
|
|
227
227
|
assert.deepEqual(tree.getComponentNames(), [])
|
|
228
228
|
})
|
|
229
229
|
})
|
|
230
|
+
|
|
231
|
+
describe('getEmptyContainers', () => {
|
|
232
|
+
it('should return container items that have no children', () => {
|
|
233
|
+
// in the shared fixture the menu (id4) and second article (id6) are childless
|
|
234
|
+
const empty = new ContentTree(items).getEmptyContainers()
|
|
235
|
+
assert.deepEqual(empty.map(i => i._id.toString()).sort(), ['id4', 'id6'])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should never flag components (leaf nodes)', () => {
|
|
239
|
+
const tree = new ContentTree([
|
|
240
|
+
{ _id: makeId(1), _type: 'block', _parentId: makeId(99) },
|
|
241
|
+
{ _id: makeId(2), _type: 'component', _parentId: makeId(1), _component: 'adapt-contrib-text' }
|
|
242
|
+
])
|
|
243
|
+
assert.equal(tree.getEmptyContainers().length, 0)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should never flag config (childless root)', () => {
|
|
247
|
+
const tree = new ContentTree([
|
|
248
|
+
{ _id: makeId(1), _type: 'course' },
|
|
249
|
+
{ _id: makeId(2), _type: 'config', _courseId: 'c1' },
|
|
250
|
+
{ _id: makeId(3), _type: 'page', _parentId: makeId(1) },
|
|
251
|
+
{ _id: makeId(4), _type: 'article', _parentId: makeId(3) },
|
|
252
|
+
{ _id: makeId(5), _type: 'block', _parentId: makeId(4) },
|
|
253
|
+
{ _id: makeId(6), _type: 'component', _parentId: makeId(5), _component: 'adapt-contrib-text' }
|
|
254
|
+
])
|
|
255
|
+
assert.deepEqual(tree.getEmptyContainers(), [])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should flag a block with no components', () => {
|
|
259
|
+
const tree = new ContentTree([
|
|
260
|
+
{ _id: makeId(1), _type: 'article', _parentId: makeId(99) },
|
|
261
|
+
{ _id: makeId(2), _type: 'block', _parentId: makeId(1) }
|
|
262
|
+
])
|
|
263
|
+
const empty = tree.getEmptyContainers()
|
|
264
|
+
assert.equal(empty.length, 1)
|
|
265
|
+
assert.equal(empty[0]._id.toString(), 'id2')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should flag a course with no children', () => {
|
|
269
|
+
const tree = new ContentTree([
|
|
270
|
+
{ _id: makeId(1), _type: 'course' }
|
|
271
|
+
])
|
|
272
|
+
assert.deepEqual(tree.getEmptyContainers().map(i => i._type), ['course'])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should return empty array for an empty tree', () => {
|
|
276
|
+
assert.deepEqual(new ContentTree([]).getEmptyContainers(), [])
|
|
277
|
+
})
|
|
278
|
+
})
|
|
230
279
|
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import buildAssetUsagePipeline from '../lib/utils/buildAssetUsagePipeline.js'
|
|
4
|
+
|
|
5
|
+
const GROUP = { $group: { _id: '$_assetIds', courses: { $addToSet: '$_courseId' } } }
|
|
6
|
+
const PROJECT = { $project: { courseCount: { $size: '$courses' } } }
|
|
7
|
+
|
|
8
|
+
describe('buildAssetUsagePipeline', () => {
|
|
9
|
+
for (const [name, input] of [
|
|
10
|
+
['no argument', undefined],
|
|
11
|
+
['null', null],
|
|
12
|
+
['empty array', []]
|
|
13
|
+
]) {
|
|
14
|
+
it(`counts all assets when given ${name} (no $match, single $unwind)`, () => {
|
|
15
|
+
const pipeline = buildAssetUsagePipeline(input)
|
|
16
|
+
assert.deepEqual(pipeline, [
|
|
17
|
+
{ $unwind: '$_assetIds' },
|
|
18
|
+
GROUP,
|
|
19
|
+
PROJECT
|
|
20
|
+
])
|
|
21
|
+
assert.equal(pipeline.filter(s => s.$match).length, 0)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('scopes with a $match before and after $unwind when asset ids are given', () => {
|
|
26
|
+
const ids = ['id-a', 'id-b']
|
|
27
|
+
const pipeline = buildAssetUsagePipeline(ids)
|
|
28
|
+
const expectedMatch = { $match: { _assetIds: { $in: ids } } }
|
|
29
|
+
assert.deepEqual(pipeline, [
|
|
30
|
+
expectedMatch,
|
|
31
|
+
{ $unwind: '$_assetIds' },
|
|
32
|
+
expectedMatch,
|
|
33
|
+
GROUP,
|
|
34
|
+
PROJECT
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('places the pre-$unwind $match first so the _assetIds index can be used', () => {
|
|
39
|
+
const pipeline = buildAssetUsagePipeline(['id-a'])
|
|
40
|
+
assert.ok(pipeline[0].$match, 'first stage should be a $match')
|
|
41
|
+
assert.equal(pipeline[1].$unwind, '$_assetIds')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('counts distinct courses (uses $addToSet on _courseId, not a document count)', () => {
|
|
45
|
+
const group = buildAssetUsagePipeline().find(s => s.$group)
|
|
46
|
+
assert.deepEqual(group.$group.courses, { $addToSet: '$_courseId' })
|
|
47
|
+
})
|
|
48
|
+
})
|