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.
@@ -0,0 +1,35 @@
1
+ import { App } from 'adapt-authoring-core'
2
+ import fs from 'fs/promises'
3
+ import path from 'upath'
4
+
5
+ /**
6
+ * Copies the framework source directory
7
+ * @param {Object} options
8
+ * @param {String} options.destDir The destination directory path
9
+ * @param {Array<String>} options.enabledPlugins List of plugins to include
10
+ * @param {Boolean} options.copyNodeModules Whether to physically copy node_modules
11
+ * @param {Boolean} options.linkNodeModules Whether to symlink node_modules
12
+ * @return {Promise}
13
+ */
14
+ export async function copyFrameworkSource (options) {
15
+ const { path: fwPath } = await App.instance.waitForModule('adaptframework')
16
+ const BLACKLIST = ['.git', '.DS_Store', 'thumbs.db', 'course', 'migrations']
17
+ if (options.copyNodeModules !== true) BLACKLIST.push('node_modules')
18
+
19
+ const srcDir = path.join(fwPath, 'src')
20
+ const enabledPlugins = options.enabledPlugins ?? []
21
+ await fs.cp(fwPath, options.destDir, {
22
+ recursive: true,
23
+ filter: f => {
24
+ f = path.normalize(f)
25
+ const [type, name] = path.relative(srcDir, f).split('/')
26
+ const isPlugin = f.startsWith(srcDir) && type && type !== 'core' && !!name
27
+
28
+ if (isPlugin && !enabledPlugins.includes(name)) {
29
+ return false
30
+ }
31
+ return !BLACKLIST.includes(path.basename(f))
32
+ }
33
+ })
34
+ if (options.linkNodeModules !== false) await fs.symlink(`${fwPath}/node_modules`, `${options.destDir}/node_modules`, 'junction')
35
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Returns a map of content types and their instance count in the content JSON
3
+ * @param {Object} content Course content
4
+ * @returns {Object}
5
+ */
6
+ export function getImportContentCounts (content) {
7
+ return Object.values(content).reduce((m, c) => {
8
+ const items = c._type ? [c] : Object.values(c)
9
+ return items.reduce((m, { _type }) => {
10
+ return { ...m, [_type]: m[_type] !== undefined ? m[_type] + 1 : 1 }
11
+ }, m)
12
+ }, {})
13
+ }
@@ -0,0 +1,55 @@
1
+ import { App } from 'adapt-authoring-core'
2
+ import { getPluginUpdateStatus } from './getPluginUpdateStatus.js'
3
+ import { getImportContentCounts } from './getImportContentCounts.js'
4
+
5
+ /**
6
+ * @typedef {AdaptFrameworkImportSummary}
7
+ * @property {String} title Course title
8
+ * @property {String} courseId Course _id
9
+ * @property {Object} statusReport Status report
10
+ * @property {Object<String>} statusReport.info Information messages
11
+ * @property {Array<String>} statusReport.warn Warning messages
12
+ * @property {Object} content Object mapping content types to the number of items of that type found in the imported course
13
+ * @property {Object} versions A map of plugins used in the imported course and their versions
14
+ *
15
+ * @param {AdaptFrameworkImport} importer The import instance
16
+ * @return {AdaptFrameworkImportSummary} Object mapping all import versions to server installed versions
17
+ * @example
18
+ * {
19
+ * adapt_framework: [1.0.0, 2.0.0],
20
+ * adapt-contrib-vanilla: [1.0.0, 2.0.0]
21
+ * }
22
+ */
23
+ export async function getImportSummary (importer) {
24
+ const [framework, contentplugin] = await App.instance.waitForModule('adaptframework', 'contentplugin')
25
+ const installedPlugins = await contentplugin.find()
26
+ const {
27
+ pkg: { name: fwName, version: fwVersion },
28
+ idMap: { course: courseId },
29
+ contentJson,
30
+ usedContentPlugins: usedPlugins,
31
+ newContentPlugins: newPlugins,
32
+ statusReport,
33
+ settings: { updatePlugins }
34
+ } = importer
35
+ const versions = [
36
+ { name: fwName, versions: [framework.version, fwVersion] },
37
+ ...Object.values(usedPlugins),
38
+ ...Object.values(newPlugins)
39
+ ].map(meta => {
40
+ const p = installedPlugins.find(p => p.name === meta.name)
41
+ const versions = meta.versions ?? [p?.version, meta.version]
42
+ return {
43
+ name: meta.name,
44
+ status: getPluginUpdateStatus(versions, p?.isLocalInstall, updatePlugins),
45
+ versions
46
+ }
47
+ })
48
+ return {
49
+ title: contentJson.course.displayTitle || contentJson.course.title,
50
+ courseId,
51
+ statusReport,
52
+ content: getImportContentCounts(contentJson),
53
+ versions
54
+ }
55
+ }
@@ -0,0 +1,20 @@
1
+ import semver from 'semver'
2
+
3
+ /**
4
+ * Determines the update status code for a plugin based on version comparison
5
+ * @param {Array} versions Tuple of [installedVersion, importVersion]
6
+ * @param {Boolean} isLocalInstall Whether the plugin is a local install
7
+ * @param {Boolean} updatePlugins Whether plugin updates are enabled
8
+ * @returns {String} The update status code
9
+ */
10
+ export function getPluginUpdateStatus (versions, isLocalInstall, updatePlugins) {
11
+ const [installedVersion, importVersion] = versions
12
+ if (!semver.valid(importVersion)) return 'INVALID'
13
+ if (!installedVersion) return 'INSTALLED'
14
+ if (semver.lt(importVersion, installedVersion)) return 'OLDER'
15
+ if (semver.gt(importVersion, installedVersion)) {
16
+ if (!updatePlugins && !isLocalInstall) return 'UPDATE_BLOCKED'
17
+ return 'UPDATED'
18
+ }
19
+ return 'NO_CHANGE'
20
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Infers the framework action to be executed from a given request URL
3
+ * @param {external:ExpressRequest} req
4
+ * @return {String}
5
+ */
6
+ export function inferBuildAction (req) {
7
+ const end = req.url.indexOf('/', 1)
8
+ return req.url.slice(1, end === -1 ? undefined : end)
9
+ }
@@ -0,0 +1,37 @@
1
+ import { App } from 'adapt-authoring-core'
2
+ import bytes from 'bytes'
3
+ import fsSync from 'fs'
4
+ import path from 'upath'
5
+
6
+ let fw
7
+
8
+ /**
9
+ * Logs a message using the framework module
10
+ * @param {...*} args Arguments to be logged
11
+ */
12
+ export async function log (...args) {
13
+ if (!fw) fw = await App.instance.waitForModule('adaptframework')
14
+ return fw.log(...args)
15
+ }
16
+
17
+ /**
18
+ * Logs directory path and file mode information
19
+ * @param {string} label Label for the directory
20
+ * @param {string} dir Directory path
21
+ */
22
+ export function logDir (label, dir) {
23
+ try {
24
+ const resolved = dir ? path.resolve(dir) : undefined
25
+ log('verbose', 'DIR', label, resolved)
26
+ if (resolved) log('verbose', 'DIR_MODE', label, fsSync.statSync(resolved).mode)
27
+ } catch (e) {
28
+ log('warn', `failed to log dir ${label} (${dir}), ${e.code}`)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Logs current memory usage statistics
34
+ */
35
+ export function logMemory () {
36
+ log('verbose', 'MEMORY', Object.entries(process.memoryUsage()).reduce((m, [k, v]) => Object.assign(m, { [k]: bytes.parse(v) }), {}))
37
+ }
@@ -0,0 +1,18 @@
1
+ import { App } from 'adapt-authoring-core'
2
+
3
+ /** @ignore */ const buildCache = {}
4
+
5
+ /**
6
+ * Retrieves metadata for a build attempt
7
+ * @param {String} id ID of build document
8
+ * @return {Promise}
9
+ */
10
+ export async function retrieveBuildData (id) {
11
+ if (buildCache[id]) {
12
+ return buildCache[id]
13
+ }
14
+ const mdb = await App.instance.waitForModule('mongodb')
15
+ const [data] = await mdb.find('adaptbuilds', { _id: id })
16
+ buildCache[id] = data
17
+ return data
18
+ }
@@ -0,0 +1,24 @@
1
+ import AdaptCli from 'adapt-cli'
2
+ import { App } from 'adapt-authoring-core'
3
+
4
+ /**
5
+ * Wrapper for running adapt-cli commands
6
+ * @param {string} command Command to run
7
+ * @param {object} opts Extra options
8
+ */
9
+ export async function runCliCommand (command, opts = {}) {
10
+ if (typeof AdaptCli[command] !== 'function') {
11
+ throw App.instance.errors.FW_CLI_UNKNOWN_CMD.setData({ command })
12
+ }
13
+ const debugLog = (...args) => App.instance.logger.log('debug', 'adapt-cli', ...args)
14
+
15
+ App.instance.logger.log('verbose', 'adapt-cli', 'CMD_START', command, opts)
16
+ const res = await AdaptCli[command]({
17
+ cwd: App.instance.config.get('adapt-authoring-adaptframework.frameworkDir'),
18
+ repository: App.instance.config.get('adapt-authoring-adaptframework.frameworkRepository'),
19
+ logger: { log: debugLog, logProgress: debugLog, write: debugLog },
20
+ ...opts
21
+ })
22
+ App.instance.logger.log('verbose', 'adapt-cli', 'CMD_END', command, opts)
23
+ return res
24
+ }
@@ -0,0 +1,18 @@
1
+ import { App } from 'adapt-authoring-core'
2
+
3
+ /**
4
+ * Returns a 'slugified' version of the course title appropriate for a filename
5
+ * @param {Object} buildData The course build data
6
+ * @returns {string} The slugified title
7
+ */
8
+ export async function slugifyTitle (buildData) {
9
+ const content = await App.instance.waitForModule('content')
10
+ const [course] = await content.find({ _id: buildData.courseId })
11
+ const sanitisedTitle = course.title
12
+ .toLowerCase()
13
+ .trim()
14
+ .replace(/[^a-z0-9 -]/g, '') // remove non-alphanumeric
15
+ .replace(/\s+/g, '-') // replace spaces with hyphens
16
+ .replace(/-+/g, '-') // remove duplicate hyphens
17
+ return `${sanitisedTitle}${buildData.action === 'export' ? '-export' : ''}`
18
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,9 @@
1
+ export { inferBuildAction } from './utils/inferBuildAction.js'
2
+ export { getPluginUpdateStatus } from './utils/getPluginUpdateStatus.js'
3
+ export { getImportContentCounts } from './utils/getImportContentCounts.js'
4
+ export { log, logDir, logMemory } from './utils/log.js'
5
+ export { runCliCommand } from './utils/runCliCommand.js'
6
+ export { retrieveBuildData } from './utils/retrieveBuildData.js'
7
+ export { getImportSummary } from './utils/getImportSummary.js'
8
+ export { slugifyTitle } from './utils/slugifyTitle.js'
9
+ export { copyFrameworkSource } from './utils/copyFrameworkSource.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-adaptframework",
3
- "version": "1.12.0",
3
+ "version": "2.0.0",
4
4
  "description": "Adapt framework integration for the Adapt authoring tool",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-adaptframework",
6
6
  "license": "GPL-3.0",
@@ -14,7 +14,7 @@
14
14
  "adapt-authoring-browserslist": "^1.2.1",
15
15
  "adapt-authoring-content": "^1.2.3",
16
16
  "adapt-authoring-contentplugin": "^1.0.3",
17
- "adapt-authoring-core": "^1.7.0",
17
+ "adapt-authoring-core": "^2.0.0",
18
18
  "adapt-authoring-courseassets": "^1.0.3",
19
19
  "adapt-authoring-coursetheme": "^1.0.2",
20
20
  "adapt-authoring-spoortracking": "^1.0.2",
@@ -1,9 +1,10 @@
1
- import { describe, it, before, after } from 'node:test'
1
+ import { describe, it, after } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import _ from 'lodash'
4
4
  import fs from 'fs/promises'
5
5
  import path from 'path'
6
6
  import { fileURLToPath } from 'url'
7
+ import { ensureDir } from 'adapt-authoring-core'
7
8
  import AdaptFrameworkBuild from '../lib/AdaptFrameworkBuild.js'
8
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -113,26 +114,21 @@ describe('AdaptFrameworkBuild', () => {
113
114
  })
114
115
  })
115
116
 
116
- describe('#ensureDir()', () => {
117
+ describe('ensureDir() (from core)', () => {
117
118
  const testDir = path.join(__dirname, 'data', 'ensure-dir-test')
118
- let build
119
-
120
- before(() => {
121
- build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
122
- })
123
119
 
124
120
  after(async () => {
125
121
  await fs.rm(testDir, { recursive: true, force: true })
126
122
  })
127
123
 
128
124
  it('should create a directory that does not exist', async () => {
129
- await build.ensureDir(testDir)
125
+ await ensureDir(testDir)
130
126
  const stat = await fs.stat(testDir)
131
127
  assert.ok(stat.isDirectory())
132
128
  })
133
129
 
134
130
  it('should not throw when the directory already exists', async () => {
135
- await build.ensureDir(testDir)
131
+ await ensureDir(testDir)
136
132
  const stat = await fs.stat(testDir)
137
133
  assert.ok(stat.isDirectory())
138
134
  })
@@ -0,0 +1,30 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { getImportContentCounts } from '../lib/utils/getImportContentCounts.js'
5
+
6
+ describe('getImportContentCounts()', () => {
7
+ it('should count single items by _type', () => {
8
+ const content = {
9
+ course: { _type: 'course' },
10
+ config: { _type: 'config' }
11
+ }
12
+ assert.deepEqual(getImportContentCounts(content), { course: 1, config: 1 })
13
+ })
14
+
15
+ it('should count arrays of items by _type', () => {
16
+ const content = {
17
+ course: { _type: 'course' },
18
+ contentObjects: {
19
+ co1: { _type: 'page' },
20
+ co2: { _type: 'page' },
21
+ co3: { _type: 'menu' }
22
+ }
23
+ }
24
+ assert.deepEqual(getImportContentCounts(content), { course: 1, page: 2, menu: 1 })
25
+ })
26
+
27
+ it('should return empty object for empty content', () => {
28
+ assert.deepEqual(getImportContentCounts({}), {})
29
+ })
30
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { getPluginUpdateStatus } from '../lib/utils/getPluginUpdateStatus.js'
5
+
6
+ describe('getPluginUpdateStatus()', () => {
7
+ it('should return "INVALID" for an invalid import version', () => {
8
+ assert.equal(getPluginUpdateStatus(['1.0.0', 'not-valid'], false, false), 'INVALID')
9
+ })
10
+
11
+ it('should return "INSTALLED" when no installed version exists', () => {
12
+ assert.equal(getPluginUpdateStatus([undefined, '1.0.0'], false, false), 'INSTALLED')
13
+ })
14
+
15
+ it('should return "OLDER" when import version is older', () => {
16
+ assert.equal(getPluginUpdateStatus(['2.0.0', '1.0.0'], false, false), 'OLDER')
17
+ })
18
+
19
+ it('should return "NO_CHANGE" when versions are equal', () => {
20
+ assert.equal(getPluginUpdateStatus(['1.0.0', '1.0.0'], false, false), 'NO_CHANGE')
21
+ })
22
+
23
+ it('should return "UPDATE_BLOCKED" when import is newer but updates not enabled and not local', () => {
24
+ assert.equal(getPluginUpdateStatus(['1.0.0', '2.0.0'], false, false), 'UPDATE_BLOCKED')
25
+ })
26
+
27
+ it('should return "UPDATED" when import is newer and updates are enabled', () => {
28
+ assert.equal(getPluginUpdateStatus(['1.0.0', '2.0.0'], false, true), 'UPDATED')
29
+ })
30
+
31
+ it('should return "UPDATED" when import is newer and is a local install', () => {
32
+ assert.equal(getPluginUpdateStatus(['1.0.0', '2.0.0'], true, false), 'UPDATED')
33
+ })
34
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { inferBuildAction } from '../lib/utils/inferBuildAction.js'
5
+
6
+ describe('inferBuildAction()', () => {
7
+ const cases = [
8
+ { url: '/preview/abc123', expected: 'preview' },
9
+ { url: '/publish/abc123', expected: 'publish' },
10
+ { url: '/export/abc123', expected: 'export' }
11
+ ]
12
+ cases.forEach(({ url, expected }) => {
13
+ it(`should return "${expected}" for URL "${url}"`, () => {
14
+ assert.equal(inferBuildAction({ url }), expected)
15
+ })
16
+ })
17
+
18
+ it('should return full action for URLs without trailing slash', () => {
19
+ assert.equal(inferBuildAction({ url: '/import' }), 'import')
20
+ })
21
+ })