adapt-authoring-content 3.4.0 → 3.5.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/adapt-authoring.json +4 -1
- package/docs/content-model.md +244 -0
- package/lib/ContentModule.js +26 -0
- package/package.json +1 -1
- package/routes.json +12 -0
package/adapt-authoring.json
CHANGED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Content model
|
|
2
|
+
|
|
3
|
+
`adapt-authoring-content` stores every piece of authored course content as a flat
|
|
4
|
+
collection of documents (`collectionName = 'content'`). It extends
|
|
5
|
+
`AbstractApiModule`, so it inherits standard REST CRUD plus the access/hook
|
|
6
|
+
machinery, and layers on the tree structure, friendly IDs, asset indexing and
|
|
7
|
+
build-time validation described here.
|
|
8
|
+
|
|
9
|
+
Source: `lib/ContentModule.js`, `lib/ContentTree.js`, `lib/utils/*`.
|
|
10
|
+
|
|
11
|
+
## Hierarchy
|
|
12
|
+
|
|
13
|
+
Content forms a tree whose nodes are distinguished by `_type`:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
course
|
|
17
|
+
├── config (one per course, a childless sibling — not a tree child)
|
|
18
|
+
├── menu (optional; a menu can nest pages/menus)
|
|
19
|
+
└── page
|
|
20
|
+
└── article
|
|
21
|
+
└── block
|
|
22
|
+
└── component (leaf node)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The recursive scaffold order is hard-coded in `insertRecursive`
|
|
26
|
+
(`ContentModule.js`):
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
['course', 'page', 'article', 'block', 'component']
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`menu` is inserted as a special case (when `req.body._type === 'menu'`); `config`
|
|
33
|
+
replaces `course` in the chain when a new course is created.
|
|
34
|
+
|
|
35
|
+
Every `_type` maps to a JSON schema via `contentTypeToSchemaName`
|
|
36
|
+
(`lib/utils/contentTypeToSchemaName.js`): `page` and `menu` both resolve to
|
|
37
|
+
`contentobject`; all other types map to a schema of the same name. A `component`
|
|
38
|
+
is special — its schema is derived from its plugin's `targetAttribute`
|
|
39
|
+
(see `getSchemaName`), e.g. `adapt-contrib-text` → `text-component`.
|
|
40
|
+
|
|
41
|
+
## Linking fields
|
|
42
|
+
|
|
43
|
+
Items are joined by ID references, not nesting:
|
|
44
|
+
|
|
45
|
+
- `_parentId` — the immediate parent item. `config` has none; `course` has none.
|
|
46
|
+
- `_courseId` — the owning course. Set on every descendant. A `course` document
|
|
47
|
+
gets its own `_id` written back as `_courseId` after insert (see `insert`),
|
|
48
|
+
so a single `{ _courseId }` query returns the whole course incl. the course doc.
|
|
49
|
+
|
|
50
|
+
Indexes built in `init` reflect the common access paths:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
{ _courseId: 1, _parentId: 1, _type: 1 }
|
|
54
|
+
{ _parentId: 1 }
|
|
55
|
+
{ _type: 1, _courseId: 1 }
|
|
56
|
+
{ _assetIds: 1 }
|
|
57
|
+
{ _courseId: 1, _friendlyId: 1 } // unique, partial (string & non-empty)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## ContentTree
|
|
61
|
+
|
|
62
|
+
`lib/ContentTree.js` is a pure, DB-free structure over a flat array of items
|
|
63
|
+
(one course's worth). It builds O(1) lookup maps on construction (`byId`,
|
|
64
|
+
`byParent`, `byType`) and exposes `course`/`config` directly. It runs on both
|
|
65
|
+
server and client.
|
|
66
|
+
|
|
67
|
+
Key methods:
|
|
68
|
+
|
|
69
|
+
| Method | Returns |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `getById(id)` | item, O(1) |
|
|
72
|
+
| `getChildren(parentId)` | direct children, O(1) |
|
|
73
|
+
| `getByType(type)` | all items of a type, O(1) |
|
|
74
|
+
| `getDescendants(rootId)` | all descendants (BFS), O(n) |
|
|
75
|
+
| `getAncestors(itemId)` | parent chain upward, O(depth) |
|
|
76
|
+
| `getSiblings(itemId)` | siblings excluding self |
|
|
77
|
+
| `getEmptyContainers()` | childless non-`component`/non-`config` items |
|
|
78
|
+
| `getComponentNames()` | unique `_component` values in the course |
|
|
79
|
+
|
|
80
|
+
The module builds a `ContentTree` internally for delete, clone, sort-order and
|
|
81
|
+
plugin-list maintenance — anywhere it needs the whole course in memory.
|
|
82
|
+
|
|
83
|
+
## Tree endpoint
|
|
84
|
+
|
|
85
|
+
`GET /api/content/tree/:_courseId` (`handleTree`, `routes.json`) returns a
|
|
86
|
+
lightweight projection of every item in a course for rendering tree/list views
|
|
87
|
+
without fetching full documents. Permission: `read:content`.
|
|
88
|
+
|
|
89
|
+
Projected fields (`treeFields` in `handleTree`):
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
_id, _parentId, _courseId, _type, _sortOrder, title, displayTitle,
|
|
93
|
+
_friendlyId, _component, _layout, _menu, _theme, _enabledPlugins,
|
|
94
|
+
_colorLabel, _language, heroImage, updatedAt
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each returned item also carries a `_children` array of child `_id`s, computed
|
|
98
|
+
from the in-memory tree.
|
|
99
|
+
|
|
100
|
+
Caching is via a **weak ETag**, not `Last-Modified` (despite the `routes.json`
|
|
101
|
+
OpenAPI `meta` still describing `If-Modified-Since`). `treeEtag`
|
|
102
|
+
(`lib/utils/treeEtag.js`) folds the course `updatedAt` and a hash of the
|
|
103
|
+
projected field list together, so the cache busts both when the course changes
|
|
104
|
+
*and* when the response shape changes — a field added to the projection won't
|
|
105
|
+
stay missing for unedited courses. If `If-None-Match` matches, the handler
|
|
106
|
+
returns `304`.
|
|
107
|
+
|
|
108
|
+
The course `updatedAt` is bumped on any descendant change via `touchCourse`,
|
|
109
|
+
tapped into `postInsertHook`/`postUpdateHook`/`postDeleteHook` (`init`). The
|
|
110
|
+
handler also enforces course-level access itself (owner / `_isShared` /
|
|
111
|
+
`_shareWithUsers` / shared group), returning `404` rather than `403` so it
|
|
112
|
+
doesn't leak existence; supers are exempt.
|
|
113
|
+
|
|
114
|
+
## CRUD, scaffold, clone, reorder
|
|
115
|
+
|
|
116
|
+
### insert
|
|
117
|
+
On insert (`insert`): a `_friendlyId` is generated if absent, `_assetIds` is
|
|
118
|
+
computed if absent, then `super.insert` runs. A new `course` gets its `_courseId`
|
|
119
|
+
written back. Otherwise `updateSortOrder` and `updateEnabledPlugins` run unless
|
|
120
|
+
disabled via the `updateSortOrder: false` / `updateEnabledPlugins: false`
|
|
121
|
+
options. A duplicate-key error is rethrown as `DUPL_FRIENDLY_ID` (409).
|
|
122
|
+
|
|
123
|
+
### insertRecursive
|
|
124
|
+
`POST /api/content/insertrecursive` (`handleInsertRecursive` → `insertRecursive`)
|
|
125
|
+
bootstraps a parent plus all required children in one call. With no `rootId` it
|
|
126
|
+
creates a course (+ config + a default page/article/block/text-component);
|
|
127
|
+
with a `rootId` it fills in the missing descendant types below that parent.
|
|
128
|
+
Defaults (titles, the default `adapt-contrib-text` component body) are pulled
|
|
129
|
+
from langpack strings via `req.translate('app.…')`. On any failure all
|
|
130
|
+
just-created items are rolled back. Sort-order/plugin side effects run once for
|
|
131
|
+
the topmost new item.
|
|
132
|
+
|
|
133
|
+
### clone
|
|
134
|
+
`POST /api/content/clone` (`handleClone` → `clone`) duplicates an item and all
|
|
135
|
+
descendants in a single bulk `insertMany`. It pre-generates new `ObjectId`s
|
|
136
|
+
(old→new map), remaps `_parentId`/`_courseId`, bulk-allocates friendly IDs per
|
|
137
|
+
type, then fires `preInsertHook`/`postInsertHook` per payload and
|
|
138
|
+
`preCloneHook`/`postCloneHook` per item. Cloning a `course` also clones its
|
|
139
|
+
`config`. `clone` accepts `_parentId` for re-homing; an invalid parent throws
|
|
140
|
+
`INVALID_PARENT`.
|
|
141
|
+
|
|
142
|
+
Clone-specific hooks (created in `init`):
|
|
143
|
+
|
|
144
|
+
- `preCloneHook` — mutable; invoked per source item before cloning.
|
|
145
|
+
- `postCloneHook` — invoked per item with `(originalItem, newDoc)`.
|
|
146
|
+
|
|
147
|
+
### delete
|
|
148
|
+
`delete` cascades: it builds the course tree, collects `getDescendants`, bulk-
|
|
149
|
+
deletes them via raw mongodb (avoiding per-item hook storms), deletes the target
|
|
150
|
+
via `super.delete` (to trigger delete middleware), then invokes `postDeleteHook`
|
|
151
|
+
once with the full descendants list. Deleting a `course` also removes its
|
|
152
|
+
`config` and its friendly-ID counters (`deleteCounters`). Sort-order and the
|
|
153
|
+
course plugin list are recalculated afterwards.
|
|
154
|
+
|
|
155
|
+
### Reordering — `_sortOrder`
|
|
156
|
+
Siblings under one parent are ordered by an integer `_sortOrder` starting at 1.
|
|
157
|
+
`updateSortOrder` re-fetches siblings sorted by `_sortOrder` and delegates to
|
|
158
|
+
`computeSortOrderOps` (`lib/utils/computeSortOrderOps.js`), which splices the
|
|
159
|
+
moved/inserted item to its target index and emits the minimal set of
|
|
160
|
+
`updateOne` ops to renumber. `course` and `config` (and any parentless item)
|
|
161
|
+
are exempt. On `update`, sort recalculation only runs when `_sortOrder` or
|
|
162
|
+
`_parentId` is in the update data.
|
|
163
|
+
|
|
164
|
+
## `_friendlyId`
|
|
165
|
+
|
|
166
|
+
A human-readable per-course identifier (`formatFriendlyId`,
|
|
167
|
+
`lib/utils/formatFriendlyId.js`):
|
|
168
|
+
|
|
169
|
+
- `course` → `course-<n>` (with `-<language>` suffix when `_language` is given)
|
|
170
|
+
- `config` → `config`
|
|
171
|
+
- everything else → `<first-letter-of-type>-<n>`, e.g. `p-3`, `b-12`, `c-40`
|
|
172
|
+
|
|
173
|
+
IDs are unique per course (enforced by the partial unique index above).
|
|
174
|
+
Sequence numbers come from an atomic counter collection
|
|
175
|
+
(`contentcounters`, keyed `{ _type, _courseId }`). `generateFriendlyIds`
|
|
176
|
+
reserves a range in one `$inc`, seeding the counter from existing content
|
|
177
|
+
(`findMaxSeq` → `parseMaxSeq`, which extracts the numeric part of existing IDs)
|
|
178
|
+
on first use.
|
|
179
|
+
|
|
180
|
+
## `_assetIds`
|
|
181
|
+
|
|
182
|
+
Each content document carries `_assetIds`: the unique IDs of assets it
|
|
183
|
+
references. The field is added by extending the `content` schema with
|
|
184
|
+
`contentassets` (`schema/contentassets.schema.json`; `editorOnly`, hidden in
|
|
185
|
+
the UI), wired in `init` via `jsonschema.extendSchema('content', 'contentassets')`.
|
|
186
|
+
|
|
187
|
+
`computeAssetIds` → `extractAssetIds` (`lib/utils/extractAssetIds.js`) walks the
|
|
188
|
+
item's built schema for `_backboneForms` `Asset`-type fields and collects their
|
|
189
|
+
non-URL values. `_assetIds` is computed on insert and **recomputed on every
|
|
190
|
+
update** from the full merged document (stored as `ObjectId`s to match query
|
|
191
|
+
coercion).
|
|
192
|
+
|
|
193
|
+
Two consumers:
|
|
194
|
+
|
|
195
|
+
- `assets.preDeleteHook` → `enforceAssetNotInUse`: refuses to delete an asset
|
|
196
|
+
still referenced by content, throwing `RESOURCE_IN_USE` with the affected
|
|
197
|
+
course titles.
|
|
198
|
+
- `POST /api/content/assetusage` (`handleAssetUsage`): returns a map of asset
|
|
199
|
+
`_id` → distinct-course count, via `buildAssetUsagePipeline`
|
|
200
|
+
(`lib/utils/buildAssetUsagePipeline.js`). An optional `assetIds` body array
|
|
201
|
+
scopes the counts; assets with no usage are omitted. Counting distinct
|
|
202
|
+
`_courseId` (`$addToSet`) means many references within one course count once.
|
|
203
|
+
|
|
204
|
+
## `_enabledPlugins`
|
|
205
|
+
|
|
206
|
+
The course `config` document holds `_enabledPlugins` — the list of content
|
|
207
|
+
plugins (components, the menu, the theme, extensions) in use. `updateEnabledPlugins`
|
|
208
|
+
recomputes it from the tree's component names plus `config._menu`/`config._theme`,
|
|
209
|
+
preserving existing extensions. When new plugins are added it re-validates the
|
|
210
|
+
affected content types (`menu`/`page` for `contentobject`-targeted schemas) to
|
|
211
|
+
apply their schema defaults. It runs on insert/clone and on updates that touch
|
|
212
|
+
`_component`, `_menu`, `_theme` or `_enabledPlugins`.
|
|
213
|
+
|
|
214
|
+
`getSchema` reads `config._enabledPlugins` for the course so it only merges the
|
|
215
|
+
schemas of enabled plugins; built schemas are cached per
|
|
216
|
+
`schemaName + enabledPlugins` key (`_schemaCache`), cleared on
|
|
217
|
+
`jsonschema.registerSchemasHook`.
|
|
218
|
+
|
|
219
|
+
## Build-time validation
|
|
220
|
+
|
|
221
|
+
When `adaptframework` is available, the module taps its `preBuildHook` with
|
|
222
|
+
`enforceNoEmptyContainers` (`init`). This builds a `ContentTree` for the course
|
|
223
|
+
and calls `getEmptyContainers()`; if any non-`component`, non-`config` item has
|
|
224
|
+
no children (an empty page/article/block, or empty menu/course) it throws
|
|
225
|
+
`EMPTY_CONTAINERS` (400) listing the offending items, blocking the build.
|
|
226
|
+
`content` depends on `adaptframework`'s hook (not vice-versa), so the tap is
|
|
227
|
+
deferred via `waitForModule(...).then(...)` rather than awaited in `init`.
|
|
228
|
+
|
|
229
|
+
## Configuration
|
|
230
|
+
|
|
231
|
+
`conf/config.schema.json` exposes only pagination options (inherited API
|
|
232
|
+
behaviour); there are no content-domain config keys:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"defaultPageSize": { "type": "number", "default": 500 },
|
|
237
|
+
"maxPageSize": { "type": "number", "default": 500 }
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Errors
|
|
242
|
+
|
|
243
|
+
`errors/errors.json`: `INVALID_PARENT` (400), `DUPL_FRIENDLY_ID` (409),
|
|
244
|
+
`RESOURCE_IN_USE` (400), `EMPTY_CONTAINERS` (400).
|
package/lib/ContentModule.js
CHANGED
|
@@ -150,6 +150,32 @@ class ContentModule extends AbstractApiModule {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Returns the courses that reference a given asset as `{ _id, title }` rows, for the asset sheet's "used in courses" list.
|
|
155
|
+
* @param {external:ExpressRequest} req
|
|
156
|
+
* @param {external:ExpressResponse} res
|
|
157
|
+
* @param {function} next
|
|
158
|
+
* @return {Promise}
|
|
159
|
+
*/
|
|
160
|
+
async handleAssetCourses (req, res, next) {
|
|
161
|
+
try {
|
|
162
|
+
const usedBy = await this.find(
|
|
163
|
+
{ _assetIds: req.apiData.query._id },
|
|
164
|
+
{ validate: false },
|
|
165
|
+
{ projection: { _courseId: 1 } }
|
|
166
|
+
)
|
|
167
|
+
const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
|
|
168
|
+
const courses = await this.find(
|
|
169
|
+
{ _type: 'course', _id: { $in: courseIds } },
|
|
170
|
+
{ validate: false },
|
|
171
|
+
{ projection: { title: 1, displayTitle: 1 } }
|
|
172
|
+
)
|
|
173
|
+
res.json(courses.map(c => ({ _id: c._id.toString(), title: c.displayTitle || c.title })))
|
|
174
|
+
} catch (e) {
|
|
175
|
+
next(e)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
/**
|
|
154
180
|
* "Unused assets" filter for the asset manager. The UI sends a `?unused`
|
|
155
181
|
* query-string flag (see adapt-authoring-ui Assets page); restrict the assets
|
package/package.json
CHANGED
package/routes.json
CHANGED
|
@@ -133,6 +133,18 @@
|
|
|
133
133
|
"responses": { "200": { "description": "Object mapping asset _id to the number of distinct courses referencing it; assets with no usage are omitted" } }
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"route": "/assetusage/:_id",
|
|
139
|
+
"handlers": { "get": "handleAssetCourses" },
|
|
140
|
+
"permissions": { "get": ["read:${scope}"] },
|
|
141
|
+
"meta": {
|
|
142
|
+
"get": {
|
|
143
|
+
"summary": "List the courses that reference an asset",
|
|
144
|
+
"parameters": [{ "name": "_id", "in": "path", "description": "The asset _id", "required": true }],
|
|
145
|
+
"responses": { "200": { "description": "Array of { _id, title } for each course referencing the asset" } }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
136
148
|
}
|
|
137
149
|
]
|
|
138
150
|
}
|