adapt-authoring-adaptframework 0.0.1

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,517 @@
1
+ import _ from 'lodash'
2
+ import { App, Hook } from 'adapt-authoring-core'
3
+ import { createWriteStream } from 'fs'
4
+ import AdaptCli from 'adapt-cli'
5
+ import AdaptFrameworkUtils from './AdaptFrameworkUtils.js'
6
+ import fs from 'fs-extra'
7
+ import path from 'upath'
8
+ import semver from 'semver'
9
+ import zipper from 'zipper'
10
+
11
+ /**
12
+ * Encapsulates all behaviour needed to build a single Adapt course instance
13
+ * @memberof adaptframework
14
+ */
15
+ class AdaptFrameworkBuild {
16
+ /**
17
+ * Imports a course zip to the database
18
+ * @param {AdaptFrameworkBuildOptions} options
19
+ * @return {Promise} Resolves to this AdaptFrameworkBuild instance
20
+ */
21
+ static async run (options) {
22
+ const instance = new AdaptFrameworkBuild(options)
23
+ await instance.build()
24
+ return instance
25
+ }
26
+
27
+ /**
28
+ * Returns a timestring to be used for an adaptbuild expiry
29
+ * @return {String}
30
+ */
31
+ static async getBuildExpiry () {
32
+ const framework = await App.instance.waitForModule('adaptframework')
33
+ return new Date(Date.now() + framework.getConfig('buildLifespan')).toISOString()
34
+ }
35
+
36
+ /**
37
+ * Options to be passed to AdaptFrameworkBuild
38
+ * @typedef {Object} AdaptFrameworkBuildOptions
39
+ * @property {String} action The type of build to execute
40
+ * @property {String} courseId The course to build
41
+ * @property {String} userId The user executing the build
42
+ * @property {String} expiresAt When the build expires
43
+ *
44
+ * @constructor
45
+ * @param {AdaptFrameworkBuildOptions} options
46
+ */
47
+ constructor ({ action, courseId, userId, expiresAt }) {
48
+ /**
49
+ * The MongoDB collection name
50
+ * @type {String}
51
+ */
52
+ this.collectionName = 'adaptbuilds'
53
+ /**
54
+ * The build action being performed
55
+ * @type {String}
56
+ */
57
+ this.action = action
58
+ /**
59
+ * Shorthand for checking if this build is a preview
60
+ * @type {Boolean}
61
+ */
62
+ this.isPreview = action === 'preview'
63
+ /**
64
+ * Shorthand for checking if this build is a publish
65
+ * @type {Boolean}
66
+ */
67
+ this.isPublish = action === 'publish'
68
+ /**
69
+ * Shorthand for checking if this build is an export
70
+ * @type {Boolean}
71
+ */
72
+ this.isExport = action === 'export'
73
+ /**
74
+ * The _id of the course being build
75
+ * @type {String}
76
+ */
77
+ this.courseId = courseId
78
+ /**
79
+ * All JSON data describing this course
80
+ * @type {Object}
81
+ */
82
+ this.expiresAt = expiresAt
83
+ /**
84
+ * All JSON data describing this course
85
+ * @type {Object}
86
+ */
87
+ this.courseData = {}
88
+ /**
89
+ * All metadata related to assets used in this course
90
+ * @type {Object}
91
+ */
92
+ this.assetData = {}
93
+ /**
94
+ * Metadata describing this build attempt
95
+ * @type {Object}
96
+ */
97
+ this.buildData = {}
98
+ /**
99
+ * A map of _ids for use with 'friendly' IDs
100
+ * @type {Object}
101
+ */
102
+ this.idMap = {}
103
+ /**
104
+ * _id of the user initiating the course build
105
+ * @type {String}
106
+ */
107
+ this.userId = userId
108
+ /**
109
+ * The build output directory
110
+ * @type {String}
111
+ */
112
+ this.dir = ''
113
+ /**
114
+ * The course build directory
115
+ * @type {String}
116
+ */
117
+ this.buildDir = ''
118
+ /**
119
+ * The course content directory
120
+ * @type {String}
121
+ */
122
+ this.courseDir = ''
123
+ /**
124
+ * The final location of the build
125
+ * @type {String}
126
+ */
127
+ this.location = ''
128
+ /**
129
+ * List of plugins used in this course
130
+ * @type {Array<Object>}
131
+ */
132
+ this.enabledPlugins = []
133
+ /**
134
+ * List of plugins NOT used in this course
135
+ * @type {Array<Object>}
136
+ */
137
+ this.disabledPlugins = []
138
+ /**
139
+ * Invoked prior to a course being built.
140
+ * @type {Hook}
141
+ */
142
+ this.preBuildHook = new Hook({ mutable: true })
143
+ /**
144
+ * Invoked after a course has been built.
145
+ * @type {Hook}
146
+ */
147
+ this.postBuildHook = new Hook({ mutable: true })
148
+ }
149
+
150
+ /**
151
+ * Makes sure the directory exists
152
+ * @param {string} dir
153
+ */
154
+ async ensureDir (dir) {
155
+ try {
156
+ await fs.mkdir(dir, { recursive: true })
157
+ } catch (e) {
158
+ if (e.code !== 'EEXIST') throw e
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Runs the Adapt framework build tools to generate a course build
164
+ * @return {Promise} Resolves with the output directory
165
+ */
166
+ async build () {
167
+ await this.removeOldBuilds()
168
+
169
+ const framework = await App.instance.waitForModule('adaptframework')
170
+ if (!this.expiresAt) {
171
+ this.expiresAt = await AdaptFrameworkBuild.getBuildExpiry()
172
+ }
173
+ this.dir = path.resolve(framework.getConfig('buildDir'), new Date().getTime().toString())
174
+ this.buildDir = path.join(this.dir, 'build')
175
+ this.courseDir = path.join(this.buildDir, 'course')
176
+
177
+ const cacheDir = path.join(framework.getConfig('buildDir'), 'cache')
178
+
179
+ await this.ensureDir(this.dir)
180
+ await this.ensureDir(this.buildDir)
181
+ await this.ensureDir(cacheDir)
182
+
183
+ AdaptFrameworkUtils.logDir('dir', this.dir)
184
+ AdaptFrameworkUtils.logDir('buildDir', this.buildDir)
185
+ AdaptFrameworkUtils.logDir('cacheDir', this.cacheDir)
186
+
187
+ await this.loadCourseData()
188
+
189
+ await Promise.all([
190
+ this.copyAssets(),
191
+ this.copySource()
192
+ ])
193
+ await this.preBuildHook.invoke(this)
194
+ await framework.preBuildHook.invoke(this)
195
+
196
+ await this.writeContentJson()
197
+
198
+ AdaptFrameworkUtils.logDir('courseDir', this.courseDir)
199
+
200
+ if (!this.isExport) {
201
+ try {
202
+ AdaptFrameworkUtils.logMemory()
203
+ await AdaptCli.buildCourse({
204
+ cwd: this.dir,
205
+ sourceMaps: !this.isPublish,
206
+ outputDir: this.buildDir,
207
+ cachePath: path.resolve(cacheDir, this.courseId),
208
+ logger: { log: (...args) => App.instance.logger.log('debug', 'adapt-cli', ...args) }
209
+ })
210
+ AdaptFrameworkUtils.logMemory()
211
+ } catch (e) {
212
+ AdaptFrameworkUtils.logMemory()
213
+ throw App.instance.errors.FW_CLI_BUILD_FAILED
214
+ .setData(e)
215
+ }
216
+ }
217
+ this.isPreview ? await this.createPreview() : await this.createZip()
218
+
219
+ await this.postBuildHook.invoke(this)
220
+ await framework.postBuildHook.invoke(this)
221
+
222
+ this.buildData = await this.recordBuildAttempt()
223
+ }
224
+
225
+ /**
226
+ * Collects and caches all the DB data for the course being built
227
+ * @return {Promise}
228
+ */
229
+ async loadCourseData () {
230
+ const content = await App.instance.waitForModule('content')
231
+ const [course] = await content.find({ _id: this.courseId, _type: 'course' })
232
+ if (!course) {
233
+ throw App.instance.errors.NOT_FOUND.setData({ type: 'course', id: this.courseId })
234
+ }
235
+ const langDir = path.join(this.courseDir, 'en')
236
+ this.courseData = {
237
+ course: { dir: langDir, fileName: 'course.json', data: undefined },
238
+ config: { dir: this.courseDir, fileName: 'config.json', data: undefined },
239
+ contentObject: { dir: langDir, fileName: 'contentObjects.json', data: [] },
240
+ article: { dir: langDir, fileName: 'articles.json', data: [] },
241
+ block: { dir: langDir, fileName: 'blocks.json', data: [] },
242
+ component: { dir: langDir, fileName: 'components.json', data: [] }
243
+ }
244
+ await this.loadAssetData()
245
+ const contentItems = [course, ...await content.find({ _courseId: course._id })]
246
+ this.createIdMap(contentItems)
247
+ this.sortContentItems(contentItems)
248
+ await this.cachePluginData()
249
+ await this.transformContentItems(contentItems)
250
+ }
251
+
252
+ /**
253
+ * Processes and caches the course's assets
254
+ * @return {Promise}
255
+ */
256
+ async loadAssetData () {
257
+ const [assets, courseassets, mongodb, tags] = await App.instance.waitForModule('assets', 'courseassets', 'mongodb', 'tags')
258
+
259
+ const caRecs = await courseassets.find({ courseId: this.courseId })
260
+ const uniqueAssetIds = new Set(caRecs.map(c => mongodb.ObjectId.parse(c.assetId)))
261
+ const usedAssets = await assets.find({ _id: { $in: [...uniqueAssetIds] } })
262
+
263
+ const usedTagIds = new Set(usedAssets.reduce((m, a) => [...m, ...(a.tags ?? [])], []))
264
+ const usedTags = await tags.find({ _id: { $in: [...usedTagIds] } })
265
+ const tagTitleLookup = t => usedTags.find(u => u._id.toString() === t.toString()).title
266
+
267
+ const idMap = {}
268
+ const assetDocs = []
269
+ const courseDir = this.courseData.course.dir
270
+
271
+ await Promise.all(usedAssets.map(async a => {
272
+ assetDocs.push({ ...a, tags: a?.tags?.map(tagTitleLookup) })
273
+ if (!idMap[a._id]) idMap[a._id] = a.url ? a.url : path.join(courseDir, 'assets', a.path)
274
+ }))
275
+ this.assetData = { dir: courseDir, fileName: 'assets.json', idMap, data: assetDocs }
276
+ }
277
+
278
+ /**
279
+ * Caches lists of which plugins are/aren't being used in this course
280
+ * @return {Promise}
281
+ */
282
+ async cachePluginData () {
283
+ const all = (await (await App.instance.waitForModule('contentplugin')).find({}))
284
+ .reduce((m, p) => Object.assign(m, { [p.name]: p }), {})
285
+
286
+ const _cachePluginDeps = (p, memo = {}) => {
287
+ Object.entries(p?.pluginDependencies ?? {}).forEach(([name, version]) => {
288
+ const p = memo[name] ?? all[name]
289
+ const e = !p
290
+ ? App.instance.errors.FW_MISSING_PLUGIN_DEP.setData({ name })
291
+ : !semver.satisfies(p.version, version) ? App.instance.errors.FW_INCOMPAT_PLUGIN_DEP.setData({ name, version }) : undefined
292
+ if (e) {
293
+ throw e.setData({ name, version })
294
+ }
295
+ if (!memo[name]) {
296
+ _cachePluginDeps(p, memo)
297
+ memo[name] = p
298
+ }
299
+ })
300
+ return memo
301
+ }
302
+ const enabled = (this.courseData.config.data._enabledPlugins || [])
303
+ .reduce((plugins, name) => {
304
+ const p = all[name]
305
+ return Object.assign(plugins, { [name]: p, ..._cachePluginDeps(p) })
306
+ }, {})
307
+
308
+ Object.entries(all).forEach(([name, p]) => (enabled[name] ? this.enabledPlugins : this.disabledPlugins).push(p))
309
+ }
310
+
311
+ /**
312
+ * Stores a map of friendlyId values to ObjectId _ids
313
+ */
314
+ createIdMap (items) {
315
+ items.forEach(i => {
316
+ this.idMap[i._id] = i._friendlyId
317
+ })
318
+ }
319
+
320
+ /**
321
+ * Sorts the course data into the types needed for each Adapt JSON file. Works by memoising items into an object using the relative sort order as a key used for sorting.
322
+ * @param {Array<Object>} items The list of content objects
323
+ */
324
+ sortContentItems (items) {
325
+ const getSortOrderStr = co => (co._type === 'course' ? '1' : co._sortOrder.toString()).padStart(4, '0') // note we pad to allow 9999 children
326
+ const coMap = items.reduce((m, item) => Object.assign(m, { [item._id]: item }), {}) // object mapping items to their _id for easy lookup
327
+ const sorted = {}
328
+ items.forEach(i => {
329
+ const type = i._type === 'page' || i._type === 'menu' ? 'contentObject' : i._type
330
+ if (type === 'course' || type === 'config') {
331
+ this.courseData[type].data = i
332
+ return // don't sort the course or config items
333
+ }
334
+ if (!sorted[type]) sorted[type] = {}
335
+ // recursively calculate a sort order which is relative to the entire course for comparison
336
+ let sortOrder = ''
337
+ for (let item = i; item; sortOrder = getSortOrderStr(item) + sortOrder, item = coMap[item._parentId]);
338
+ sorted[type][sortOrder.padEnd(64, '0')] = i // pad the final string for comparison purposes
339
+ }) // finally populate this.courseData with the sorted items
340
+ Object.entries(sorted).forEach(([type, data]) => {
341
+ this.courseData[type].data = Object.keys(data).sort().map(key => data[key])
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Transforms content items into a format recognised by the Adapt framework
347
+ */
348
+ async transformContentItems (items) {
349
+ items.forEach(i => {
350
+ // slot any _friendlyIds into the _id field
351
+ ['_courseId', '_parentId'].forEach(k => {
352
+ i[k] = this.idMap[i[k]] || i[k]
353
+ })
354
+ if (i._friendlyId) {
355
+ i._id = i._friendlyId
356
+ }
357
+ // replace asset _ids with correct paths
358
+ const idMapEntries = Object.entries(this.assetData.idMap)
359
+ const itemString = idMapEntries.reduce((s, [_id, assetPath]) => {
360
+ const relPath = assetPath.replace(this.courseDir, 'course')
361
+ return s.replace(new RegExp(_id, 'g'), relPath)
362
+ }, JSON.stringify(i))
363
+ Object.assign(i, JSON.parse(itemString))
364
+ // insert expected _component values
365
+ if (i._component) {
366
+ i._component = this.enabledPlugins.find(p => p.name === i._component).targetAttribute.slice(1)
367
+ }
368
+ })
369
+ // move globals to a nested _extensions object as expected by the framework
370
+ this.enabledPlugins.forEach(({ targetAttribute, type }) => {
371
+ let key = `_${type}`
372
+ if (type === 'component' || type === 'extension') key += 's'
373
+ try {
374
+ _.merge(this.courseData.course.data._globals, {
375
+ [key]: { [targetAttribute]: this.courseData.course.data._globals[targetAttribute] }
376
+ })
377
+ delete this.courseData.course.data._globals[targetAttribute]
378
+ } catch (e) {}
379
+ })
380
+ // map course tag values (_id -> title)
381
+ const tags = await App.instance.waitForModule('tags')
382
+ const course = this.courseData.course.data
383
+ if (course?.tags?.length) {
384
+ course.tags = (await tags.find({ $or: course.tags.map(_id => Object.create({ _id })) }))
385
+ .map(t => t.title)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Copies the source code needed for this course. Plugin copy works as a whitelist any unknown plugin files aren't included erroneously.
391
+ * @return {Promise}
392
+ */
393
+ async copySource () {
394
+ const { path: fwPath } = await App.instance.waitForModule('adaptframework')
395
+ const BLACKLIST = ['.git', '.DS_Store', 'thumbs.db', 'node_modules', 'course']
396
+ const ENABLED_PLUGINS = this.enabledPlugins.map(p => p.name)
397
+ const srcDir = path.join(fwPath, 'src')
398
+ await fs.copy(fwPath, this.dir, {
399
+ filter: f => {
400
+ f = path.normalize(f)
401
+ const [type, name] = path.relative(srcDir, f).split('/');
402
+ const isPlugin = f.startsWith(srcDir) && type && type !== 'core' && !!name
403
+
404
+ if(isPlugin && !ENABLED_PLUGINS.includes(name)) {
405
+ return false
406
+ }
407
+ return !BLACKLIST.includes(path.basename(f))
408
+ }
409
+ })
410
+ if (!this.isExport) await fs.symlink(`${fwPath}/node_modules`, `${this.dir}/node_modules`, 'junction')
411
+ }
412
+
413
+ /**
414
+ * Deals with copying all assets used in this course
415
+ * @return {Promise}
416
+ */
417
+ async copyAssets () {
418
+ const assets = await App.instance.waitForModule('assets')
419
+ return Promise.all(this.assetData.data.map(async a => {
420
+ if (a.url) {
421
+ return
422
+ }
423
+ await this.ensureDir(path.dirname(this.assetData.idMap[a._id]))
424
+ const inputStream = await assets.createFsWrapper(a).read(a)
425
+ const outputStream = createWriteStream(this.assetData.idMap[a._id])
426
+ inputStream.pipe(outputStream)
427
+ return new Promise((resolve, reject) => {
428
+ inputStream.on('end', () => resolve())
429
+ outputStream.on('error', e => reject(e))
430
+ })
431
+ }))
432
+ }
433
+
434
+ /**
435
+ * Outputs all course data to the required JSON files
436
+ * @return {Promise}
437
+ */
438
+ async writeContentJson () {
439
+ const data = Object.values(this.courseData)
440
+ if (this.isExport && this.assetData.data.length) {
441
+ this.assetData.data = this.assetData.data.map(d => {
442
+ return {
443
+ title: d.title,
444
+ description: d.description,
445
+ filename: d.path,
446
+ tags: d.tags
447
+ }
448
+ })
449
+ data.push(this.assetData)
450
+ }
451
+ return Promise.all(data.map(async ({ dir, fileName, data }) => {
452
+ await this.ensureDir(dir)
453
+ const filepath = path.join(dir, fileName)
454
+ const returnData = await fs.writeJson(filepath, data, { spaces: 2 })
455
+ AdaptFrameworkUtils.log('debug', 'WRITE', filepath)
456
+ return returnData
457
+ }))
458
+ }
459
+
460
+ /**
461
+ * Makes sure the output folder is structured to allow the files to be served statically for previewing
462
+ * @return {Promise}
463
+ */
464
+ async createPreview () {
465
+ const tempName = `${this.dir}_temp`
466
+ await fs.move(path.join(this.dir, 'build'), tempName)
467
+ await fs.remove(this.dir)
468
+ await fs.move(tempName, this.dir)
469
+ this.location = this.dir
470
+ }
471
+
472
+ /**
473
+ * Creates a zip file containing all files relevant to the type of build being performed
474
+ * @return {Promise}
475
+ */
476
+ async createZip () {
477
+ const zipPath = path.join(this.dir, this.isPublish ? 'build' : '')
478
+ const outputPath = `${this.dir}.zip`
479
+ await zipper.zip(zipPath, outputPath, { removeSource: true })
480
+ await fs.remove(this.dir)
481
+ this.location = outputPath
482
+ }
483
+
484
+ /**
485
+ * Stored metadata about a build attempt in the DB
486
+ * @return {Promise} Resolves with the DB document
487
+ */
488
+ async recordBuildAttempt () {
489
+ const [framework, jsonschema, mongodb] = await App.instance.waitForModule('adaptframework', 'jsonschema', 'mongodb')
490
+ const schema = await jsonschema.getSchema('adaptbuild')
491
+ const validatedData = await schema.validate({
492
+ action: this.action,
493
+ courseId: this.courseId,
494
+ location: this.location,
495
+ expiresAt: this.expiresAt,
496
+ createdBy: this.userId,
497
+ versions: this.enabledPlugins.reduce((m, p) => {
498
+ return { ...m, [p.name]: p.version }
499
+ }, { adapt_framework: framework.version })
500
+ })
501
+ return mongodb.insert(this.collectionName, validatedData)
502
+ }
503
+
504
+ /**
505
+ * Removes all previous builds of this.action type
506
+ * @return {Promise}
507
+ */
508
+ async removeOldBuilds () {
509
+ const mongodb = await App.instance.waitForModule('mongodb')
510
+ const query = { action: this.action, createdBy: this.userId }
511
+ const oldBuilds = await mongodb.find(this.collectionName, query)
512
+ await Promise.all(oldBuilds.map(b => fs.remove(b.location)))
513
+ return mongodb.deleteMany(this.collectionName, query)
514
+ }
515
+ }
516
+
517
+ export default AdaptFrameworkBuild