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