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.
- package/index.js +0 -1
- package/lib/AdaptFrameworkBuild.js +17 -28
- package/lib/AdaptFrameworkImport.js +47 -47
- package/lib/AdaptFrameworkModule.js +12 -11
- package/lib/handlers.js +164 -0
- package/lib/utils/copyFrameworkSource.js +35 -0
- package/lib/utils/getImportContentCounts.js +13 -0
- package/lib/utils/getImportSummary.js +55 -0
- package/lib/utils/getPluginUpdateStatus.js +20 -0
- package/lib/utils/inferBuildAction.js +9 -0
- package/lib/utils/log.js +37 -0
- package/lib/utils/retrieveBuildData.js +18 -0
- package/lib/utils/runCliCommand.js +24 -0
- package/lib/utils/slugifyTitle.js +18 -0
- package/lib/utils.js +9 -0
- package/package.json +2 -2
- package/tests/AdaptFrameworkBuild.spec.js +5 -9
- package/tests/utils-getImportContentCounts.spec.js +30 -0
- package/tests/utils-getPluginUpdateStatus.spec.js +34 -0
- package/tests/utils-inferBuildAction.spec.js +21 -0
- package/lib/AdaptFrameworkUtils.js +0 -398
- package/tests/AdaptFrameworkUtils.spec.js +0 -163
|
@@ -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
|
+
}
|
package/lib/utils/log.js
ADDED
|
@@ -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": "
|
|
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": "^
|
|
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,
|
|
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('
|
|
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
|
|
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
|
|
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
|
+
})
|