Adapt authoring tool UI documentation

v1.0.0-rc.4

adapt-authoring-adaptframework/lib/AdaptFrameworkUtils.js

  1. import { App } from 'adapt-authoring-core'
  2. import FrameworkBuild from './AdaptFrameworkBuild.js'
  3. import FrameworkImport from './AdaptFrameworkImport.js'
  4. import fs from 'fs'
  5. import path from 'upath'
  6. import semver from 'semver'
  7. /** @ignore */ const buildCache = {}
  8. let fw
  9. /**
  10. * Logs a message using the framework module
  11. * @param {...*} args Arguments to be logged
  12. */
  13. /** @ignore */
  14. async function log (...args) {
  15. if (!fw) fw = await App.instance.waitForModule('adaptframework')
  16. return fw.log(...args)
  17. }
  18. /**
  19. * Utilities for use with the AdaptFrameworkModule
  20. * @memberof adaptframework
  21. */
  22. class AdaptFrameworkUtils {
  23. /**
  24. * Infers the framework action to be executed from a given request URL
  25. * @param {external:ExpressRequest} req
  26. * @return {String}
  27. */
  28. static inferBuildAction (req) {
  29. return req.url.slice(1, req.url.indexOf('/', 1))
  30. }
  31. /**
  32. * Retrieves metadata for a build attempt
  33. * @param {String} id ID of build document
  34. * @return {Promise}
  35. */
  36. static async retrieveBuildData (id) {
  37. if (buildCache[id]) {
  38. return buildCache[id]
  39. }
  40. const mdb = await App.instance.waitForModule('mongodb')
  41. const [data] = await mdb.find('adaptbuilds', { _id: id })
  42. buildCache[id] = data
  43. return data
  44. }
  45. /**
  46. * @typedef {AdaptFrameworkImportSummary}
  47. * @property {String} title Course title
  48. * @property {String} courseId Course _id
  49. * @property {Object} statusReport Status report
  50. * @property {Object<String>} statusReport.info Information messages
  51. * @property {Array<String>} statusReport.warn Warning messages
  52. * @property {Object} content Object mapping content types to the number of items of that type found in the imported course
  53. * @property {Object} versions A map of plugins used in the imported course and their versions
  54. *
  55. * @param {AdaptFrameworkImport} importer The import instance
  56. * @return {AdaptFrameworkImportSummary} Object mapping all import versions to server installed versions
  57. * @example
  58. * {
  59. * adapt_framework: [1.0.0, 2.0.0],
  60. * adapt-contrib-vanilla: [1.0.0, 2.0.0]
  61. * }
  62. */
  63. static async getImportSummary (importer) {
  64. const [framework, contentplugin] = await App.instance.waitForModule('adaptframework', 'contentplugin')
  65. const installedPlugins = await contentplugin.find()
  66. const {
  67. pkg: { name: fwName, version: fwVersion },
  68. idMap: { course: courseId },
  69. contentJson,
  70. usedContentPlugins: usedPlugins,
  71. newContentPlugins: newPlugins,
  72. statusReport,
  73. settings: { updatePlugins }
  74. } = importer
  75. const versions = [
  76. { name: fwName, versions: [framework.version, fwVersion] },
  77. ...Object.values(usedPlugins),
  78. ...Object.values(newPlugins)
  79. ].map(meta => {
  80. const p = installedPlugins.find(p => p.name === meta.name)
  81. const versions = meta.versions ?? [p?.version, meta.version]
  82. return {
  83. name: meta.name,
  84. status: this.getPluginUpdateStatus(versions, p?.isLocalInstall, updatePlugins),
  85. versions
  86. }
  87. })
  88. return {
  89. title: contentJson.course.displayTitle || contentJson.course.title,
  90. courseId,
  91. statusReport,
  92. content: this.getImportContentCounts(contentJson),
  93. versions
  94. }
  95. }
  96. /**
  97. * Determines the update status code
  98. * @param {Array} versions
  99. * @param {Boolean} isLocalInstall
  100. * @param {Boolean} updatePlugins
  101. * @returns {String} The update status code
  102. */
  103. static getPluginUpdateStatus (versions, isLocalInstall, updatePlugins) {
  104. const [installedVersion, importVersion] = versions
  105. if (!installedVersion) return 'INSTALLED'
  106. if (semver.lt(importVersion, installedVersion)) return 'OLDER'
  107. if (semver.gt(importVersion, installedVersion)) {
  108. if (!updatePlugins && !isLocalInstall) return 'UPDATE_BLOCKED'
  109. return 'UPDATED'
  110. }
  111. return 'NO_CHANGE'
  112. }
  113. /**
  114. * Returns a map of content types and their instance count in the content JSON
  115. * @param {Object} content Course content
  116. * @returns {Object}
  117. */
  118. static getImportContentCounts (content) {
  119. return Object.values(content).reduce((m, c) => {
  120. const items = c._type ? [c] : Object.values(c)
  121. return items.reduce((m, { _type }) => {
  122. return { ...m, [_type]: m[_type] !== undefined ? m[_type] + 1 : 1 }
  123. }, m)
  124. }, {})
  125. }
  126. /**
  127. * Handles GET requests to the API
  128. * @param {external:ExpressRequest} req
  129. * @param {external:ExpressResponse} res
  130. * @param {Function} next
  131. * @return {Promise}
  132. */
  133. static async getHandler (req, res, next) {
  134. const action = AdaptFrameworkUtils.inferBuildAction(req)
  135. const id = req.params.id
  136. let buildData
  137. try {
  138. buildData = await AdaptFrameworkUtils.retrieveBuildData(id)
  139. } catch (e) {
  140. return next(e)
  141. }
  142. if (!buildData || new Date(buildData.expiresAt).getTime() < Date.now()) {
  143. return next(App.instance.errors.FW_BUILD_NOT_FOUND.setData({ _id: id }))
  144. }
  145. if (action === 'publish' || action === 'export') {
  146. res.set('content-disposition', `attachment; filename="adapt-${action}-${buildData._id}.zip"`)
  147. try {
  148. return res.sendFile(path.resolve(buildData.location))
  149. } catch (e) {
  150. return next(e)
  151. }
  152. }
  153. if (action === 'preview') {
  154. if (!req.auth.user) {
  155. return res.status(App.instance.errors.MISSING_AUTH_HEADER.statusCode).end()
  156. }
  157. const filePath = path.resolve(buildData.location, req.path.slice(req.path.indexOf(id) + id.length + 1) || 'index.html')
  158. try {
  159. await fs.promises.stat(filePath)
  160. res.sendFile(filePath)
  161. } catch (e) {
  162. if (e.code === 'ENOENT') return next(App.instance.errors.NOT_FOUND.setData({ type: 'file', id: filePath }))
  163. return next(e)
  164. }
  165. }
  166. }
  167. /**
  168. * Handles POST requests to the API
  169. * @param {external:ExpressRequest} req
  170. * @param {external:ExpressResponse} res
  171. * @param {Function} next
  172. * @return {Promise}
  173. */
  174. static async postHandler (req, res, next) {
  175. const startTime = Date.now()
  176. const framework = await App.instance.waitForModule('adaptframework')
  177. const action = AdaptFrameworkUtils.inferBuildAction(req)
  178. const courseId = req.params.id
  179. const userId = req.auth.user._id.toString()
  180. log('info', `running ${action} for course '${courseId}'`)
  181. try {
  182. const { isPreview, buildData } = await FrameworkBuild.run({ action, courseId, userId })
  183. const duration = Math.round((Date.now() - startTime) / 10) / 100
  184. log('info', `finished ${action} for course '${courseId}' in ${duration} seconds`)
  185. const urlRoot = isPreview ? framework.rootRouter.url : framework.apiRouter.url
  186. res.json({
  187. [`${action}_url`]: `${urlRoot}/${action}/${buildData._id}/`,
  188. versions: buildData.versions
  189. })
  190. } catch (e) {
  191. log('error', `failed to ${action} course '${courseId}'`)
  192. return next(e)
  193. }
  194. }
  195. /**
  196. * Handles POST /import requests to the API
  197. * @param {external:ExpressRequest} req
  198. * @param {external:ExpressResponse} res
  199. * @param {Function} next
  200. * @return {Promise}
  201. */
  202. static async importHandler (req, res, next) {
  203. try {
  204. log('info', 'running course import')
  205. await AdaptFrameworkUtils.handleImportFile(req, res)
  206. const [course] = req.fileUpload.files.course
  207. const importer = await FrameworkImport.run({
  208. unzipPath: course.filepath,
  209. userId: req.auth.user._id.toString(),
  210. isDryRun: AdaptFrameworkUtils.toBoolean(req.body.dryRun),
  211. assetFolders: req.body.formAssetFolders,
  212. tags: req.body.tags?.length > 0 ? req.body.tags?.split(',') : [],
  213. importContent: AdaptFrameworkUtils.toBoolean(req.body.importContent),
  214. importPlugins: AdaptFrameworkUtils.toBoolean(req.body.importPlugins),
  215. updatePlugins: AdaptFrameworkUtils.toBoolean(req.body.updatePlugins)
  216. })
  217. const summary = await AdaptFrameworkUtils.getImportSummary(importer)
  218. res.json(summary)
  219. } catch (e) {
  220. return next(App.instance.errors.FW_IMPORT_FAILED.setData({ error: e }))
  221. }
  222. }
  223. /**
  224. * Handles POST /update requests to the API
  225. * @param {external:ExpressRequest} req
  226. * @param {external:ExpressResponse} res
  227. * @param {Function} next
  228. * @return {Promise}
  229. */
  230. static async postUpdateHandler (req, res, next) {
  231. try {
  232. log('info', 'running framework update')
  233. const framework = await App.instance.waitForModule('adaptframework')
  234. const previousVersion = framework.version
  235. await framework.updateFramework(req.body.version)
  236. const currentVersion = framework.version !== previousVersion ? framework.version : undefined
  237. res.json({
  238. from: previousVersion,
  239. to: currentVersion
  240. })
  241. } catch (e) {
  242. return next(e)
  243. }
  244. }
  245. /**
  246. * Handles GET /update requests to the API
  247. * @param {external:ExpressRequest} req
  248. * @param {external:ExpressResponse} res
  249. * @param {Function} next
  250. * @return {Promise}
  251. */
  252. static async getUpdateHandler (req, res, next) {
  253. try {
  254. const framework = await App.instance.waitForModule('adaptframework')
  255. const current = framework.version
  256. const latest = await framework.getLatestVersion()
  257. res.json({
  258. canBeUpdated: semver.gt(latest, current),
  259. currentVersion: current,
  260. latestCompatibleVersion: latest
  261. })
  262. } catch (e) {
  263. return next(e)
  264. }
  265. }
  266. /**
  267. * Converts a body value to a valid boolean
  268. * @param {*} val
  269. * @returns {Boolean}
  270. */
  271. static toBoolean (val) {
  272. if (val !== undefined) return val === true || val === 'true'
  273. }
  274. /**
  275. * Deals with an incoming course (supports both local zip and remote URL stream)
  276. * @param {external:ExpressRequest} req
  277. * @param {external:ExpressResponse} res
  278. * @return {Promise}
  279. */
  280. static async handleImportFile (req, res) {
  281. const [fw, middleware] = await App.instance.waitForModule('adaptframework', 'middleware')
  282. const handler = req.get('Content-Type').indexOf('multipart/form-data') === 0
  283. ? middleware.fileUploadParser
  284. : middleware.urlUploadParser
  285. return new Promise((resolve, reject) => {
  286. handler(middleware.zipTypes, { maxFileSize: fw.getConfig('importMaxFileSize'), unzip: true })(req, res, e => e ? reject(e) : resolve())
  287. })
  288. }
  289. }
  290. export default AdaptFrameworkUtils