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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Efficient tree abstraction over a flat array of course content items.
3
+ * Builds O(1) lookup indexes on construction for parent-child, type, and ID queries.
4
+ * Pure data structure with no DB access — works on both server and client.
5
+ * @memberof content
6
+ */
7
+ class ContentTree {
8
+ /**
9
+ * @param {Array<Object>} items Flat array of content items (from a single course)
10
+ */
11
+ constructor (items) {
12
+ /** @type {Array<Object>} */
13
+ this.items = items
14
+ /** @type {Map<string, Object>} _id -> item */
15
+ this.byId = new Map()
16
+ /** @type {Map<string, Array<Object>>} _parentId -> [children] */
17
+ this.byParent = new Map()
18
+ /** @type {Map<string, Array<Object>>} _type -> [items] */
19
+ this.byType = new Map()
20
+ /** @type {Object|null} */
21
+ this.course = null
22
+ /** @type {Object|null} */
23
+ this.config = null
24
+
25
+ for (const item of items) {
26
+ const id = item._id.toString()
27
+ this.byId.set(id, item)
28
+
29
+ const parentId = item._parentId?.toString()
30
+ if (parentId) {
31
+ if (!this.byParent.has(parentId)) this.byParent.set(parentId, [])
32
+ this.byParent.get(parentId).push(item)
33
+ }
34
+
35
+ const type = item._type
36
+ if (!this.byType.has(type)) this.byType.set(type, [])
37
+ this.byType.get(type).push(item)
38
+
39
+ if (type === 'course') this.course = item
40
+ if (type === 'config') this.config = item
41
+ }
42
+ }
43
+
44
+ /**
45
+ * O(1) lookup by ID
46
+ * @param {string|Object} id
47
+ * @returns {Object|undefined}
48
+ */
49
+ getById (id) {
50
+ return this.byId.get(id.toString())
51
+ }
52
+
53
+ /**
54
+ * O(1) children lookup
55
+ * @param {string|Object} parentId
56
+ * @returns {Array<Object>}
57
+ */
58
+ getChildren (parentId) {
59
+ return this.byParent.get(parentId.toString()) ?? []
60
+ }
61
+
62
+ /**
63
+ * O(1) type lookup
64
+ * @param {string} type
65
+ * @returns {Array<Object>}
66
+ */
67
+ getByType (type) {
68
+ return this.byType.get(type) ?? []
69
+ }
70
+
71
+ /**
72
+ * BFS traversal to find all descendants. O(n) where n = number of descendants.
73
+ * @param {string|Object} rootId
74
+ * @returns {Array<Object>}
75
+ */
76
+ getDescendants (rootId) {
77
+ const descendants = []
78
+ const queue = [rootId.toString()]
79
+ let head = 0
80
+ while (head < queue.length) {
81
+ const children = this.byParent.get(queue[head++]) ?? []
82
+ for (const child of children) {
83
+ descendants.push(child)
84
+ queue.push(child._id.toString())
85
+ }
86
+ }
87
+ return descendants
88
+ }
89
+
90
+ /**
91
+ * Walk up the parent chain. O(d) where d = depth.
92
+ * @param {string|Object} itemId
93
+ * @returns {Array<Object>}
94
+ */
95
+ getAncestors (itemId) {
96
+ const ancestors = []
97
+ let current = this.byId.get(itemId.toString())
98
+ while (current?._parentId) {
99
+ current = this.byId.get(current._parentId.toString())
100
+ if (current) ancestors.push(current)
101
+ }
102
+ return ancestors
103
+ }
104
+
105
+ /**
106
+ * O(1) siblings lookup (excludes the item itself)
107
+ * @param {string|Object} itemId
108
+ * @returns {Array<Object>}
109
+ */
110
+ getSiblings (itemId) {
111
+ const id = itemId.toString()
112
+ const item = this.byId.get(id)
113
+ if (!item?._parentId) return []
114
+ return this.getChildren(item._parentId).filter(c => c._id.toString() !== id)
115
+ }
116
+
117
+ /**
118
+ * O(1) — unique component names across the course
119
+ * @returns {Array<string>}
120
+ */
121
+ getComponentNames () {
122
+ const names = new Set()
123
+ for (const c of this.getByType('component')) names.add(c._component)
124
+ return [...names]
125
+ }
126
+ }
127
+
128
+ export default ContentTree
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Computes the bulk-write operations needed to recalculate _sortOrder values.
3
+ * Optionally splices the target item into the siblings list at the correct position.
4
+ * @param {Array<Object>} siblings Existing siblings sorted by _sortOrder (excluding the item)
5
+ * @param {Object} [item] The item being inserted/moved — omit when deleting
6
+ * @return {Array<Object>} Array of MongoDB updateOne operations
7
+ */
8
+ export default function computeSortOrderOps (siblings, item) {
9
+ if (item) {
10
+ const newSO = item._sortOrder != null && item._sortOrder - 1 > -1 ? item._sortOrder - 1 : siblings.length
11
+ siblings.splice(newSO, 0, item)
12
+ }
13
+ const ops = []
14
+ for (let i = 0; i < siblings.length; i++) {
15
+ const _sortOrder = i + 1
16
+ if (siblings[i]._sortOrder !== _sortOrder) {
17
+ ops.push({ updateOne: { filter: { _id: siblings[i]._id }, update: { $set: { _sortOrder } } } })
18
+ }
19
+ }
20
+ return ops
21
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Maps a content _type to its corresponding schema name.
3
+ * 'page' and 'menu' both map to 'contentobject'; all other types map to themselves.
4
+ * @param {String} _type Content type (e.g. 'page', 'menu', 'article', 'block')
5
+ * @return {String}
6
+ */
7
+ export default function contentTypeToSchemaName (_type) {
8
+ return _type === 'page' || _type === 'menu' ? 'contentobject' : _type
9
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Extracts unique asset IDs from a content document by walking its schema
3
+ * for Asset-type fields and collecting non-URL values.
4
+ * @param {Object} schema The built Schema instance (must have a walk method)
5
+ * @param {Object} data The data object to search for asset values
6
+ * @return {Array<String>} Unique array of asset IDs found in the data
7
+ * @memberof content
8
+ */
9
+ export function extractAssetIds (schema, data) {
10
+ const isAssetField = (field) =>
11
+ field?._backboneForms?.type === 'Asset' || field?._backboneForms === 'Asset'
12
+
13
+ return [...new Set(
14
+ schema.walk(data, isAssetField)
15
+ .map(match => match.value?.toString())
16
+ .filter(v => v && !v.startsWith('http://') && !v.startsWith('https://'))
17
+ )]
18
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Formats a friendly ID string from content type, sequence number and optional language
3
+ * @param {String} _type Content type (e.g. 'course', 'block', 'component')
4
+ * @param {Number} count Current sequence number
5
+ * @param {String} [_language] Language code (only used for courses)
6
+ * @return {String}
7
+ */
8
+ export default function formatFriendlyId (_type, count, _language) {
9
+ if (_type === 'course') return `course-${count}${_language ? `-${_language}` : ''}`
10
+ if (_type === 'config') return 'config'
11
+ return `${_type[0]}-${count}`
12
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Parses friendly ID strings from content docs and returns the highest sequence number.
3
+ * @param {Array<Object>} docs Array of objects with a `_friendlyId` property
4
+ * @return {Number}
5
+ */
6
+ export default function parseMaxSeq (docs) {
7
+ let maxNum = 0
8
+ for (const doc of docs) {
9
+ const match = doc._friendlyId?.match(/(\d+)/)
10
+ if (match) {
11
+ const num = parseInt(match[1])
12
+ if (num > maxNum) maxNum = num
13
+ }
14
+ }
15
+ return maxNum
16
+ }
package/lib/utils.js CHANGED
@@ -1 +1,6 @@
1
- export { getDescendants } from './utils/getDescendants.js'
1
+ export { default as ContentTree } from './ContentTree.js'
2
+ export { default as computeSortOrderOps } from './utils/computeSortOrderOps.js'
3
+ export { extractAssetIds } from './utils/extractAssetIds.js'
4
+ export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaName.js'
5
+ export { default as formatFriendlyId } from './utils/formatFriendlyId.js'
6
+ export { default as parseMaxSeq } from './utils/parseMaxSeq.js'
@@ -0,0 +1,123 @@
1
+ import { ObjectId } from 'mongodb'
2
+ import formatFriendlyId from '../lib/utils/formatFriendlyId.js'
3
+ import parseMaxSeq from '../lib/utils/parseMaxSeq.js'
4
+
5
+ export default function (migration) {
6
+ migration.describe('Backfill _friendlyId and _assetIds on existing content documents')
7
+ migration.runCommand(backfillFriendlyIds)
8
+ migration.runCommand(backfillAssetIds)
9
+ }
10
+
11
+ async function backfillFriendlyIds (db, log) {
12
+ const content = db.collection('content')
13
+
14
+ const missing = await content.find({
15
+ $or: [
16
+ { _friendlyId: { $exists: false } },
17
+ { _friendlyId: '' }
18
+ ]
19
+ }).toArray()
20
+
21
+ if (missing.length === 0) {
22
+ log('info', 'migrations', 'No content documents missing _friendlyId, skipping')
23
+ return
24
+ }
25
+ log('info', 'migrations', `Backfilling _friendlyId for ${missing.length} document(s)`)
26
+
27
+ // Group by _courseId + _type to allocate sequential IDs per group
28
+ const groups = new Map()
29
+ for (const doc of missing) {
30
+ const courseId = doc._courseId?.toString() ?? 'none'
31
+ const type = doc._type
32
+ const key = `${courseId}:${type}`
33
+ if (!groups.has(key)) groups.set(key, { courseId, type, docs: [] })
34
+ groups.get(key).docs.push(doc)
35
+ }
36
+
37
+ for (const { courseId, type, docs } of groups.values()) {
38
+ if (type === 'config') {
39
+ for (const doc of docs) {
40
+ await content.updateOne({ _id: doc._id }, { $set: { _friendlyId: formatFriendlyId('config') } })
41
+ }
42
+ continue
43
+ }
44
+
45
+ const isCourse = type === 'course'
46
+ const query = {
47
+ _type: type,
48
+ _friendlyId: { $exists: true, $ne: '' }
49
+ }
50
+ if (!isCourse && courseId !== 'none') {
51
+ query._courseId = docs[0]._courseId
52
+ }
53
+
54
+ const existing = await content.find(query, { projection: { _friendlyId: 1 } }).toArray()
55
+ const maxSeq = parseMaxSeq(existing)
56
+
57
+ let nextSeq = maxSeq + 1
58
+ for (const doc of docs) {
59
+ const friendlyId = formatFriendlyId(type, nextSeq, doc._language)
60
+ await content.updateOne({ _id: doc._id }, { $set: { _friendlyId: friendlyId } })
61
+ nextSeq++
62
+ }
63
+ }
64
+ log('info', 'migrations', `Backfilled _friendlyId for ${missing.length} document(s)`)
65
+ }
66
+
67
+ async function backfillAssetIds (db, log) {
68
+ const content = db.collection('content')
69
+ const assets = db.collection('assets')
70
+
71
+ const docsToUpdate = await content.find({
72
+ $or: [
73
+ { _assetIds: { $exists: false } },
74
+ { _assetIds: null }
75
+ ]
76
+ }).toArray()
77
+
78
+ if (docsToUpdate.length === 0) {
79
+ log('info', 'migrations', 'No content documents missing _assetIds, skipping')
80
+ return
81
+ }
82
+
83
+ // Build a set of all asset ID strings for fast lookup
84
+ const assetIds = await assets.distinct('_id')
85
+ const assetIdStrings = assetIds.map(id => id.toString())
86
+
87
+ if (assetIdStrings.length === 0) {
88
+ // No assets in the DB — set all to empty array
89
+ await content.updateMany(
90
+ { $or: [{ _assetIds: { $exists: false } }, { _assetIds: null }] },
91
+ { $set: { _assetIds: [] } }
92
+ )
93
+ log('info', 'migrations', `Set _assetIds to [] for ${docsToUpdate.length} document(s) (no assets in DB)`)
94
+ return
95
+ }
96
+
97
+ log('info', 'migrations', `Backfilling _assetIds for ${docsToUpdate.length} document(s) against ${assetIdStrings.length} known asset(s)`)
98
+
99
+ let updated = 0
100
+ const ops = []
101
+ for (const doc of docsToUpdate) {
102
+ const docStr = JSON.stringify(doc)
103
+ const foundIds = assetIdStrings
104
+ .filter(id => docStr.includes(id))
105
+ .map(id => new ObjectId(id))
106
+ ops.push({
107
+ updateOne: {
108
+ filter: { _id: doc._id },
109
+ update: { $set: { _assetIds: foundIds } }
110
+ }
111
+ })
112
+ if (ops.length >= 500) {
113
+ await content.bulkWrite(ops, { ordered: false })
114
+ updated += ops.length
115
+ ops.length = 0
116
+ }
117
+ }
118
+ if (ops.length > 0) {
119
+ await content.bulkWrite(ops, { ordered: false })
120
+ updated += ops.length
121
+ }
122
+ log('info', 'migrations', `Backfilled _assetIds for ${updated} document(s)`)
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "2.1.8",
3
+ "version": "3.0.0",
4
4
  "description": "Module for managing Adapt content",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
6
  "license": "GPL-3.0",
@@ -12,13 +12,14 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "adapt-authoring-api": "^3.0.0",
15
- "adapt-authoring-core": "^2.0.0"
15
+ "adapt-authoring-core": "^2.0.0",
16
+ "adapt-authoring-mongodb": "^3.1.0"
16
17
  },
17
18
  "peerDependencies": {
19
+ "adapt-authoring-assets": "^1.0.0",
18
20
  "adapt-authoring-authored": "^1.1.1",
19
21
  "adapt-authoring-contentplugin": "^1.0.6",
20
22
  "adapt-authoring-jsonschema": "^1.2.0",
21
- "adapt-authoring-mongodb": "^3.0.0",
22
23
  "adapt-authoring-tags": "^1.0.2"
23
24
  },
24
25
  "devDependencies": {
package/routes.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "root": "content",
3
3
  "routes": [
4
+ {
5
+ "route": "/tree/:_courseId",
6
+ "handlers": { "get": "handleTree" },
7
+ "permissions": { "get": ["read:${scope}"] },
8
+ "meta": {
9
+ "get": {
10
+ "summary": "Get lightweight content tree for a course",
11
+ "description": "Returns a projected subset of fields for all content items in a course, suitable for rendering tree views without fetching full documents. Supports If-Modified-Since for conditional requests — returns 304 if the course has not been modified since the given date.",
12
+ "parameters": [
13
+ { "name": "_courseId", "in": "path", "description": "The course _id", "required": true },
14
+ { "name": "If-Modified-Since", "in": "header", "description": "Return 304 if the course has not been modified since this date", "required": false }
15
+ ],
16
+ "responses": {
17
+ "200": {
18
+ "description": "Array of projected content items",
19
+ "content": {
20
+ "application/json": {
21
+ "schema": {
22
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
23
+ "type": "array",
24
+ "items": {
25
+ "type": "object",
26
+ "properties": {
27
+ "_id": { "type": "string" },
28
+ "_parentId": { "type": "string" },
29
+ "_courseId": { "type": "string" },
30
+ "_type": { "type": "string" },
31
+ "_sortOrder": { "type": "number" },
32
+ "title": { "type": "string" },
33
+ "displayTitle": { "type": "string" },
34
+ "_friendlyId": { "type": "string" },
35
+ "_component": { "type": "string" },
36
+ "_layout": { "type": "string" },
37
+ "_menu": { "type": "string" },
38
+ "_theme": { "type": "string" },
39
+ "_enabledPlugins": { "type": "array", "items": { "type": "string" } },
40
+ "updatedAt": { "type": "string", "format": "date-time" },
41
+ "_children": { "type": "array", "items": { "type": "string" } }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ },
48
+ "304": {
49
+ "description": "Course has not been modified since the If-Modified-Since date"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
4
55
  {
5
56
  "route": "/insertrecursive",
6
57
  "handlers": { "post": "handleInsertRecursive" },
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "contentassets",
4
+ "description": "Tracks asset IDs referenced by content items",
5
+ "$merge": {
6
+ "with": {
7
+ "properties": {
8
+ "_assetIds": {
9
+ "type": "array",
10
+ "items": { "type": "string" },
11
+ "default": [],
12
+ "_adapt": { "editorOnly": true },
13
+ "_backboneForms": { "showInUi": false }
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }