adapt-authoring-adaptframework 1.12.0 → 2.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.
@@ -1,398 +0,0 @@
1
- import AdaptCli from 'adapt-cli'
2
- import { App } from 'adapt-authoring-core'
3
- import bytes from 'bytes'
4
- import fs from 'fs/promises'
5
- /* eslint-disable import/no-named-default */import { default as fsSync } from 'fs'
6
- import path from 'upath'
7
- import semver from 'semver'
8
-
9
- /** @ignore */ const buildCache = {}
10
-
11
- let fw
12
- /**
13
- * Utilities for use with the AdaptFrameworkModule
14
- * @memberof adaptframework
15
- */
16
- class AdaptFrameworkUtils {
17
- /**
18
- * Wrapper for running adapt-cli commands
19
- * @param {string} command Command to run
20
- * @param {object} opts Extra options
21
- */
22
- static async runCliCommand (command, opts = {}) {
23
- if (typeof AdaptCli[command] !== 'function') {
24
- throw App.instance.errors.FW_CLI_UNKNOWN_CMD.setData({ command })
25
- }
26
- const debugLog = (...args) => App.instance.logger.log('debug', 'adapt-cli', ...args)
27
-
28
- App.instance.logger.log('verbose', 'adapt-cli', 'CMD_START', command, opts)
29
- const res = await AdaptCli[command]({
30
- cwd: App.instance.config.get('adapt-authoring-adaptframework.frameworkDir'),
31
- repository: App.instance.config.get('adapt-authoring-adaptframework.frameworkRepository'),
32
- logger: { log: debugLog, logProgress: debugLog, write: debugLog },
33
- ...opts
34
- })
35
- App.instance.logger.log('verbose', 'adapt-cli', 'CMD_END', command, opts)
36
- return res
37
- }
38
-
39
- /**
40
- * Logs a message using the framework module
41
- * @param {...*} args Arguments to be logged
42
- */
43
- static async log (...args) {
44
- if (!fw) fw = await App.instance.waitForModule('adaptframework')
45
- return fw.log(...args)
46
- }
47
-
48
- static logDir (label, dir) {
49
- try {
50
- const resolved = dir ? path.resolve(dir) : undefined
51
- AdaptFrameworkUtils.log('verbose', 'DIR', label, resolved)
52
- if (resolved) AdaptFrameworkUtils.log('verbose', 'DIR_MODE', label, fsSync.statSync(resolved).mode)
53
- } catch (e) {
54
- AdaptFrameworkUtils.log('warn', `failed to log dir ${label} (${dir}), ${e.code}`)
55
- }
56
- }
57
-
58
- static logMemory () {
59
- AdaptFrameworkUtils.log('verbose', 'MEMORY', Object.entries(process.memoryUsage()).reduce((m, [k, v]) => Object.assign(m, { [k]: bytes.parse(v) }), {}))
60
- }
61
-
62
- static async readJson (filepath) {
63
- return JSON.parse(await fs.readFile(filepath))
64
- }
65
-
66
- static writeJson (filepath, data) {
67
- return fs.writeFile(filepath, (JSON.stringify(data, null, 2)))
68
- }
69
-
70
- /**
71
- * Infers the framework action to be executed from a given request URL
72
- * @param {external:ExpressRequest} req
73
- * @return {String}
74
- */
75
- static inferBuildAction (req) {
76
- return req.url.slice(1, req.url.indexOf('/', 1))
77
- }
78
-
79
- /**
80
- * Retrieves metadata for a build attempt
81
- * @param {String} id ID of build document
82
- * @return {Promise}
83
- */
84
- static async retrieveBuildData (id) {
85
- if (buildCache[id]) {
86
- return buildCache[id]
87
- }
88
- const mdb = await App.instance.waitForModule('mongodb')
89
- const [data] = await mdb.find('adaptbuilds', { _id: id })
90
- buildCache[id] = data
91
- return data
92
- }
93
-
94
- /**
95
- * @typedef {AdaptFrameworkImportSummary}
96
- * @property {String} title Course title
97
- * @property {String} courseId Course _id
98
- * @property {Object} statusReport Status report
99
- * @property {Object<String>} statusReport.info Information messages
100
- * @property {Array<String>} statusReport.warn Warning messages
101
- * @property {Object} content Object mapping content types to the number of items of that type found in the imported course
102
- * @property {Object} versions A map of plugins used in the imported course and their versions
103
- *
104
- * @param {AdaptFrameworkImport} importer The import instance
105
- * @return {AdaptFrameworkImportSummary} Object mapping all import versions to server installed versions
106
- * @example
107
- * {
108
- * adapt_framework: [1.0.0, 2.0.0],
109
- * adapt-contrib-vanilla: [1.0.0, 2.0.0]
110
- * }
111
- */
112
- static async getImportSummary (importer) {
113
- const [framework, contentplugin] = await App.instance.waitForModule('adaptframework', 'contentplugin')
114
- const installedPlugins = await contentplugin.find()
115
- const {
116
- pkg: { name: fwName, version: fwVersion },
117
- idMap: { course: courseId },
118
- contentJson,
119
- usedContentPlugins: usedPlugins,
120
- newContentPlugins: newPlugins,
121
- statusReport,
122
- settings: { updatePlugins }
123
- } = importer
124
- const versions = [
125
- { name: fwName, versions: [framework.version, fwVersion] },
126
- ...Object.values(usedPlugins),
127
- ...Object.values(newPlugins)
128
- ].map(meta => {
129
- const p = installedPlugins.find(p => p.name === meta.name)
130
- const versions = meta.versions ?? [p?.version, meta.version]
131
- return {
132
- name: meta.name,
133
- status: this.getPluginUpdateStatus(versions, p?.isLocalInstall, updatePlugins),
134
- versions
135
- }
136
- })
137
- return {
138
- title: contentJson.course.displayTitle || contentJson.course.title,
139
- courseId,
140
- statusReport,
141
- content: this.getImportContentCounts(contentJson),
142
- versions
143
- }
144
- }
145
-
146
- /**
147
- * Determines the update status code
148
- * @param {Array} versions
149
- * @param {Boolean} isLocalInstall
150
- * @param {Boolean} updatePlugins
151
- * @returns {String} The update status code
152
- */
153
- static getPluginUpdateStatus (versions, isLocalInstall, updatePlugins) {
154
- const [installedVersion, importVersion] = versions
155
- if (!semver.valid(importVersion)) return 'INVALID'
156
- if (!installedVersion) return 'INSTALLED'
157
- if (semver.lt(importVersion, installedVersion)) return 'OLDER'
158
- if (semver.gt(importVersion, installedVersion)) {
159
- if (!updatePlugins && !isLocalInstall) return 'UPDATE_BLOCKED'
160
- return 'UPDATED'
161
- }
162
- return 'NO_CHANGE'
163
- }
164
-
165
- /**
166
- * Returns a map of content types and their instance count in the content JSON
167
- * @param {Object} content Course content
168
- * @returns {Object}
169
- */
170
- static getImportContentCounts (content) {
171
- return Object.values(content).reduce((m, c) => {
172
- const items = c._type ? [c] : Object.values(c)
173
- return items.reduce((m, { _type }) => {
174
- return { ...m, [_type]: m[_type] !== undefined ? m[_type] + 1 : 1 }
175
- }, m)
176
- }, {})
177
- }
178
-
179
- /**
180
- * Returns a 'slugified' version of the course title appropriate for a filename
181
- * @param {Object} buildData The course build data
182
- * @returns {string} The slugified title
183
- */
184
- static async slugifyTitle (buildData) {
185
- const content = await App.instance.waitForModule('content')
186
- const [course] = await content.find({ _id: buildData.courseId })
187
- const sanitisedTitle = course.title
188
- .toLowerCase()
189
- .trim()
190
- .replace(/[^a-z0-9 -]/g, '') // remove non-alphanumeric
191
- .replace(/\s+/g, '-') // replace spaces with hyphens
192
- .replace(/-+/g, '-') // remove duplicate hyphens
193
- return `${sanitisedTitle}${buildData.action === 'export' ? '-export' : ''}`
194
- }
195
-
196
- /**
197
- * Copies the framework source directory
198
- * @param {Object} options
199
- * @param {String} options.destDir The destination directory path
200
- * @param {Array<String>} options.enabledPlugins List of plugins to include
201
- * @param {Boolean} options.copyNodeModules Whether to physically copy node_modules
202
- * @param {Boolean} options.linkNodeModules Whether to symlink node_modules
203
- * @return {Promise}
204
- */
205
- static async copyFrameworkSource (options) {
206
- const { path: fwPath } = await App.instance.waitForModule('adaptframework')
207
- const BLACKLIST = ['.git', '.DS_Store', 'thumbs.db', 'course', 'migrations']
208
- if (options.copyNodeModules !== true) BLACKLIST.push('node_modules')
209
-
210
- const srcDir = path.join(fwPath, 'src')
211
- const enabledPlugins = options.enabledPlugins ?? []
212
- await fs.cp(fwPath, options.destDir, {
213
- recursive: true,
214
- filter: f => {
215
- f = path.normalize(f)
216
- const [type, name] = path.relative(srcDir, f).split('/')
217
- const isPlugin = f.startsWith(srcDir) && type && type !== 'core' && !!name
218
-
219
- if (isPlugin && !enabledPlugins.includes(name)) {
220
- return false
221
- }
222
- return !BLACKLIST.includes(path.basename(f))
223
- }
224
- })
225
- if (options.linkNodeModules !== false) await fs.symlink(`${fwPath}/node_modules`, `${options.destDir}/node_modules`, 'junction')
226
- }
227
-
228
- /**
229
- * Handles GET requests to the API
230
- * @param {external:ExpressRequest} req
231
- * @param {external:ExpressResponse} res
232
- * @param {Function} next
233
- * @return {Promise}
234
- */
235
- static async getHandler (req, res, next) {
236
- const action = AdaptFrameworkUtils.inferBuildAction(req)
237
- const id = req.params.id
238
- let buildData
239
- try {
240
- buildData = await AdaptFrameworkUtils.retrieveBuildData(id)
241
- } catch (e) {
242
- return next(e)
243
- }
244
- if (!buildData || new Date(buildData.expiresAt).getTime() < Date.now()) {
245
- return next(App.instance.errors.FW_BUILD_NOT_FOUND.setData({ _id: id }))
246
- }
247
- if (action === 'publish' || action === 'export') {
248
- res.set('content-disposition', `attachment; filename="${await AdaptFrameworkUtils.slugifyTitle(buildData)}.zip"`)
249
- return res.sendFile(path.resolve(buildData.location), e => e && next(e))
250
- }
251
- if (action === 'preview') {
252
- if (!req.auth.user) {
253
- return res.status(App.instance.errors.MISSING_AUTH_HEADER.statusCode).end()
254
- }
255
- const filePath = path.resolve(buildData.location, req.path.slice(req.path.indexOf(id) + id.length + 1) || 'index.html')
256
- await res.sendFile(filePath, e => {
257
- if (!e) return
258
- if (e.code === 'ENOENT') e = App.instance.errors.NOT_FOUND.setData({ type: 'file', id: filePath })
259
- next(e)
260
- })
261
- }
262
- }
263
-
264
- /**
265
- * Handles POST requests to the API
266
- * @param {external:ExpressRequest} req
267
- * @param {external:ExpressResponse} res
268
- * @param {Function} next
269
- * @return {Promise}
270
- */
271
- static async postHandler (req, res, next) {
272
- const framework = await App.instance.waitForModule('adaptframework')
273
- const startTime = Date.now()
274
- const action = AdaptFrameworkUtils.inferBuildAction(req)
275
- const courseId = req.params.id
276
- const userId = req.auth.user._id.toString()
277
-
278
- AdaptFrameworkUtils.log('info', `running ${action} for course '${courseId}' initiated by ${userId}`)
279
- try {
280
- const { isPreview, buildData } = await framework.buildCourse({ action, courseId, userId })
281
- const duration = Math.round((Date.now() - startTime) / 10) / 100
282
- AdaptFrameworkUtils.log('info', `finished ${action} for course '${courseId}' in ${duration} seconds`)
283
- const urlRoot = isPreview ? framework.rootRouter.url : framework.apiRouter.url
284
- res.json({
285
- [`${action}_url`]: `${urlRoot}/${action}/${buildData._id}/`,
286
- versions: buildData.versions
287
- })
288
- } catch (e) {
289
- AdaptFrameworkUtils.log('error', `failed to ${action} course '${courseId}'`)
290
- return next(e)
291
- }
292
- }
293
-
294
- /**
295
- * Handles POST /import requests to the API
296
- * @param {external:ExpressRequest} req
297
- * @param {external:ExpressResponse} res
298
- * @param {Function} next
299
- * @return {Promise}
300
- */
301
- static async importHandler (req, res, next) {
302
- try {
303
- const framework = await App.instance.waitForModule('adaptframework')
304
- let importPath = req.body.importPath
305
- if (req.get('Content-Type').indexOf('multipart/form-data') === 0) {
306
- await AdaptFrameworkUtils.handleImportFile(req, res)
307
- const [course] = req.fileUpload.files.course
308
- importPath = course.filepath
309
- }
310
- const importer = await framework.importCourse({
311
- importPath,
312
- userId: req.auth.user._id.toString(),
313
- isDryRun: AdaptFrameworkUtils.toBoolean(req.body.dryRun),
314
- assetFolders: req.body.formAssetFolders,
315
- tags: req.body.tags?.length > 0 ? req.body.tags?.split(',') : [],
316
- importContent: AdaptFrameworkUtils.toBoolean(req.body.importContent),
317
- importPlugins: AdaptFrameworkUtils.toBoolean(req.body.importPlugins),
318
- migrateContent: AdaptFrameworkUtils.toBoolean(req.body.migrateContent),
319
- updatePlugins: AdaptFrameworkUtils.toBoolean(req.body.updatePlugins)
320
- })
321
- res.json(importer.summary)
322
- } catch (e) {
323
- return next(e?.statusCode ? e : App.instance.errors.FW_IMPORT_FAILED.setData({ error: e }))
324
- }
325
- }
326
-
327
- /**
328
- * Handles POST /update requests to the API
329
- * @param {external:ExpressRequest} req
330
- * @param {external:ExpressResponse} res
331
- * @param {Function} next
332
- * @return {Promise}
333
- */
334
- static async postUpdateHandler (req, res, next) {
335
- try {
336
- AdaptFrameworkUtils.log('info', 'running framework update')
337
- const framework = await App.instance.waitForModule('adaptframework')
338
- const previousVersion = framework.version
339
- await framework.updateFramework(req.body.version)
340
- const currentVersion = framework.version !== previousVersion ? framework.version : undefined
341
- res.json({
342
- from: previousVersion,
343
- to: currentVersion
344
- })
345
- } catch (e) {
346
- return next(e)
347
- }
348
- }
349
-
350
- /**
351
- * Handles GET /update requests to the API
352
- * @param {external:ExpressRequest} req
353
- * @param {external:ExpressResponse} res
354
- * @param {Function} next
355
- * @return {Promise}
356
- */
357
- static async getUpdateHandler (req, res, next) {
358
- try {
359
- const framework = await App.instance.waitForModule('adaptframework')
360
- const current = framework.version
361
- const latest = await framework.getLatestVersion()
362
- res.json({
363
- canBeUpdated: semver.gt(latest, current),
364
- currentVersion: current,
365
- latestCompatibleVersion: latest
366
- })
367
- } catch (e) {
368
- return next(e)
369
- }
370
- }
371
-
372
- /**
373
- * Converts a body value to a valid boolean
374
- * @param {*} val
375
- * @returns {Boolean}
376
- */
377
- static toBoolean (val) {
378
- if (val !== undefined) return val === true || val === 'true'
379
- }
380
-
381
- /**
382
- * Deals with an incoming course (supports both local zip and remote URL stream)
383
- * @param {external:ExpressRequest} req
384
- * @param {external:ExpressResponse} res
385
- * @return {Promise}
386
- */
387
- static async handleImportFile (req, res) {
388
- const [fw, middleware] = await App.instance.waitForModule('adaptframework', 'middleware')
389
- const handler = req.get('Content-Type').indexOf('multipart/form-data') === 0
390
- ? middleware.fileUploadParser
391
- : middleware.urlUploadParser
392
- return new Promise((resolve, reject) => {
393
- handler(middleware.zipTypes, { maxFileSize: fw.getConfig('importMaxFileSize'), unzip: true })(req, res, e => e ? reject(e) : resolve())
394
- })
395
- }
396
- }
397
-
398
- export default AdaptFrameworkUtils
@@ -1,163 +0,0 @@
1
- import { describe, it, before, after } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import fs from 'fs/promises'
4
- import path from 'path'
5
- import { fileURLToPath } from 'url'
6
- import AdaptFrameworkUtils from '../lib/AdaptFrameworkUtils.js'
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
-
10
- describe('AdaptFrameworkUtils', () => {
11
- describe('#inferBuildAction()', () => {
12
- const cases = [
13
- { url: '/preview/abc123', expected: 'preview' },
14
- { url: '/publish/abc123', expected: 'publish' },
15
- { url: '/export/abc123', expected: 'export' }
16
- ]
17
- cases.forEach(({ url, expected }) => {
18
- it(`should return "${expected}" for URL "${url}"`, () => {
19
- assert.equal(AdaptFrameworkUtils.inferBuildAction({ url }), expected)
20
- })
21
- })
22
-
23
- // TODO: inferBuildAction returns truncated result for URLs without a second slash
24
- // e.g. '/import' returns 'impor' because indexOf('/', 1) returns -1
25
- // causing slice(1, -1) to strip the last character
26
- it('should truncate action for URLs without trailing slash/path (known bug)', () => {
27
- assert.equal(AdaptFrameworkUtils.inferBuildAction({ url: '/import' }), 'impor')
28
- })
29
- })
30
-
31
- describe('#toBoolean()', () => {
32
- it('should return true for boolean true', () => {
33
- assert.equal(AdaptFrameworkUtils.toBoolean(true), true)
34
- })
35
-
36
- it('should return true for string "true"', () => {
37
- assert.equal(AdaptFrameworkUtils.toBoolean('true'), true)
38
- })
39
-
40
- it('should return false for boolean false', () => {
41
- assert.equal(AdaptFrameworkUtils.toBoolean(false), false)
42
- })
43
-
44
- it('should return false for string "false"', () => {
45
- assert.equal(AdaptFrameworkUtils.toBoolean('false'), false)
46
- })
47
-
48
- it('should return undefined for undefined', () => {
49
- assert.equal(AdaptFrameworkUtils.toBoolean(undefined), undefined)
50
- })
51
-
52
- it('should return false for null', () => {
53
- assert.equal(AdaptFrameworkUtils.toBoolean(null), false)
54
- })
55
-
56
- it('should return false for 0', () => {
57
- assert.equal(AdaptFrameworkUtils.toBoolean(0), false)
58
- })
59
-
60
- it('should return false for empty string', () => {
61
- assert.equal(AdaptFrameworkUtils.toBoolean(''), false)
62
- })
63
- })
64
-
65
- describe('#getPluginUpdateStatus()', () => {
66
- it('should return "INVALID" for an invalid import version', () => {
67
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', 'not-valid'], false, false), 'INVALID')
68
- })
69
-
70
- it('should return "INSTALLED" when no installed version exists', () => {
71
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus([undefined, '1.0.0'], false, false), 'INSTALLED')
72
- })
73
-
74
- it('should return "OLDER" when import version is older', () => {
75
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['2.0.0', '1.0.0'], false, false), 'OLDER')
76
- })
77
-
78
- it('should return "NO_CHANGE" when versions are equal', () => {
79
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '1.0.0'], false, false), 'NO_CHANGE')
80
- })
81
-
82
- it('should return "UPDATE_BLOCKED" when import is newer but updates not enabled and not local', () => {
83
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '2.0.0'], false, false), 'UPDATE_BLOCKED')
84
- })
85
-
86
- it('should return "UPDATED" when import is newer and updates are enabled', () => {
87
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '2.0.0'], false, true), 'UPDATED')
88
- })
89
-
90
- it('should return "UPDATED" when import is newer and is a local install', () => {
91
- assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '2.0.0'], true, false), 'UPDATED')
92
- })
93
- })
94
-
95
- describe('#getImportContentCounts()', () => {
96
- it('should count single items by _type', () => {
97
- const content = {
98
- course: { _type: 'course' },
99
- config: { _type: 'config' }
100
- }
101
- const result = AdaptFrameworkUtils.getImportContentCounts(content)
102
- assert.deepEqual(result, { course: 1, config: 1 })
103
- })
104
-
105
- it('should count arrays of items by _type', () => {
106
- const content = {
107
- course: { _type: 'course' },
108
- contentObjects: {
109
- co1: { _type: 'page' },
110
- co2: { _type: 'page' },
111
- co3: { _type: 'menu' }
112
- }
113
- }
114
- const result = AdaptFrameworkUtils.getImportContentCounts(content)
115
- assert.deepEqual(result, { course: 1, page: 2, menu: 1 })
116
- })
117
-
118
- it('should return empty object for empty content', () => {
119
- assert.deepEqual(AdaptFrameworkUtils.getImportContentCounts({}), {})
120
- })
121
- })
122
-
123
- describe('#readJson()', () => {
124
- const testFile = path.join(__dirname, 'data', 'test-read.json')
125
-
126
- before(async () => {
127
- await fs.mkdir(path.join(__dirname, 'data'), { recursive: true })
128
- await fs.writeFile(testFile, JSON.stringify({ key: 'value' }))
129
- })
130
-
131
- after(async () => {
132
- await fs.rm(testFile, { force: true })
133
- })
134
-
135
- it('should read and parse a JSON file', async () => {
136
- const result = await AdaptFrameworkUtils.readJson(testFile)
137
- assert.deepEqual(result, { key: 'value' })
138
- })
139
-
140
- it('should throw for a non-existent file', async () => {
141
- await assert.rejects(
142
- AdaptFrameworkUtils.readJson('/nonexistent/file.json'),
143
- { code: 'ENOENT' }
144
- )
145
- })
146
- })
147
-
148
- describe('#writeJson()', () => {
149
- const testFile = path.join(__dirname, 'data', 'test-write.json')
150
-
151
- after(async () => {
152
- await fs.rm(testFile, { force: true })
153
- })
154
-
155
- it('should write formatted JSON to a file', async () => {
156
- const data = { hello: 'world', num: 42 }
157
- await AdaptFrameworkUtils.writeJson(testFile, data)
158
- const content = await fs.readFile(testFile, 'utf8')
159
- assert.deepEqual(JSON.parse(content), data)
160
- assert.ok(content.includes('\n'), 'should be formatted with indentation')
161
- })
162
- })
163
- })