adapt-authoring-adaptframework 2.6.0 → 3.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/.github/workflows/releases.yml +2 -1
- package/conf/config.schema.json +5 -0
- package/index.js +1 -1
- package/lib/AdaptFrameworkBuild.js +96 -46
- package/lib/AdaptFrameworkImport.js +86 -79
- package/lib/AdaptFrameworkModule.js +86 -3
- package/lib/BuildCache.js +105 -0
- package/lib/handlers.js +3 -2
- package/lib/migrations/vanilla-background-styles.js +17 -0
- package/lib/utils/applyBuildReplacements.js +23 -0
- package/lib/utils/collectMigrationScripts.js +15 -0
- package/lib/utils/computePluginHash.js +14 -0
- package/lib/utils/copyFrameworkSource.js +2 -2
- package/lib/utils/generateLanguageManifest.js +9 -0
- package/lib/utils/log.js +5 -7
- package/lib/utils/migrateExistingCourses.js +87 -0
- package/lib/utils/prebuildCache.js +110 -0
- package/lib/utils/readFrameworkPluginVersions.js +21 -0
- package/lib/utils/runContentMigration.js +72 -0
- package/lib/utils.js +8 -0
- package/package.json +4 -5
- package/tests/AdaptFrameworkImport.spec.js +7 -13
- package/tests/BuildCache.spec.js +107 -0
- package/tests/utils-applyBuildReplacements.spec.js +57 -0
- package/tests/utils-collectMigrationScripts.spec.js +45 -0
- package/tests/utils-computePluginHash.spec.js +40 -0
- package/tests/utils-generateLanguageManifest.spec.js +27 -0
- package/tests/utils-migrateExistingCourses.spec.js +163 -0
- package/tests/utils-readFrameworkPluginVersions.spec.js +48 -0
- package/tests/utils-runContentMigration.spec.js +101 -0
|
@@ -10,7 +10,8 @@ permissions:
|
|
|
10
10
|
issues: write
|
|
11
11
|
pull-requests: write
|
|
12
12
|
id-token: write
|
|
13
|
+
packages: write
|
|
13
14
|
|
|
14
15
|
jobs:
|
|
15
16
|
release:
|
|
16
|
-
uses: adaptlearning/semantic-release-config/.github/workflows/release.yml@master
|
|
17
|
+
uses: adaptlearning/semantic-release-config/.github/workflows/release.yml@master
|
package/conf/config.schema.json
CHANGED
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"description": "URL of the Adapt framework git repository to install",
|
|
33
33
|
"type": "string"
|
|
34
34
|
},
|
|
35
|
+
"prebuildCache": {
|
|
36
|
+
"description": "When enabled, eagerly rebuilds the prebuilt cache for every (theme, menu) combination in the background after invalidation (e.g. plugin install/update/delete)",
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"default": false
|
|
39
|
+
},
|
|
35
40
|
"importMaxFileSize": {
|
|
36
41
|
"description": "Maximum file upload size for course imports",
|
|
37
42
|
"type": "string",
|
package/index.js
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
* @namespace adaptframework
|
|
4
4
|
*/
|
|
5
5
|
export { default } from './lib/AdaptFrameworkModule.js'
|
|
6
|
-
export { copyFrameworkSource } from './lib/utils.js'
|
|
6
|
+
export { copyFrameworkSource, readFrameworkPluginVersions } from './lib/utils.js'
|
|
7
7
|
export { default as AdaptFrameworkBuild } from './lib/AdaptFrameworkBuild.js'
|
|
8
8
|
export { default as AdaptFrameworkImport } from './lib/AdaptFrameworkImport.js'
|
|
@@ -3,7 +3,8 @@ import { App, Hook, ensureDir, writeJson } from 'adapt-authoring-core'
|
|
|
3
3
|
import { parseObjectId } from 'adapt-authoring-mongodb'
|
|
4
4
|
import { createWriteStream } from 'node:fs'
|
|
5
5
|
import AdaptCli from 'adapt-cli'
|
|
6
|
-
import { log, logDir, logMemory, copyFrameworkSource } from './utils.js'
|
|
6
|
+
import { log, logDir, logMemory, copyFrameworkSource, generateLanguageManifest, applyBuildReplacements } from './utils.js'
|
|
7
|
+
import BuildCache from './BuildCache.js'
|
|
7
8
|
import fs from 'node:fs/promises'
|
|
8
9
|
import path from 'upath'
|
|
9
10
|
import semver from 'semver'
|
|
@@ -199,11 +200,48 @@ class AdaptFrameworkBuild {
|
|
|
199
200
|
|
|
200
201
|
await this.loadCourseData()
|
|
201
202
|
|
|
203
|
+
// Check for cached preview build
|
|
204
|
+
if (this.isPreview && !contentOnly) {
|
|
205
|
+
const cache = new BuildCache(path.join(framework.getConfig('buildDir'), 'prebuilt-cache'))
|
|
206
|
+
const pluginHash = await framework.getPluginHash()
|
|
207
|
+
const theme = this.courseData.config.data._theme
|
|
208
|
+
const menu = this.courseData.config.data._menu
|
|
209
|
+
|
|
210
|
+
if (await cache.has(pluginHash, theme, menu)) {
|
|
211
|
+
await cache.restore(pluginHash, theme, menu, this.buildDir)
|
|
212
|
+
await this.applySchemaDefaults()
|
|
213
|
+
await this.copyAssets()
|
|
214
|
+
await this.preBuildHook.invoke(this)
|
|
215
|
+
await this.writeContentJson()
|
|
216
|
+
await this.writeLanguageManifest()
|
|
217
|
+
await applyBuildReplacements(this.buildDir, {
|
|
218
|
+
defaultLanguage: this.courseData.config.data._defaultLanguage ?? 'en',
|
|
219
|
+
defaultDirection: this.courseData.config.data._defaultDirection ?? 'ltr',
|
|
220
|
+
buildType: 'development',
|
|
221
|
+
timestamp: Date.now()
|
|
222
|
+
})
|
|
223
|
+
this.location = path.join(this.dir, 'build')
|
|
224
|
+
await this.postBuildHook.invoke(this)
|
|
225
|
+
this.buildData = await this.recordBuildAttempt()
|
|
226
|
+
return this
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
202
230
|
const tasks = [this.copyAssets()]
|
|
203
231
|
if (!contentOnly) {
|
|
232
|
+
// preview cache is shared across courses, so include all installed plugins
|
|
233
|
+
// — except disabled themes/menus, since only one of each can be active per
|
|
234
|
+
// build and the framework's less:dev task globs every theme/menu in src/,
|
|
235
|
+
// which OOMs when more than one is present (see adapt_framework#3802).
|
|
236
|
+
const pluginsToInclude = this.isPreview
|
|
237
|
+
? [
|
|
238
|
+
...this.enabledPlugins,
|
|
239
|
+
...this.disabledPlugins.filter(p => p.type !== 'theme' && p.type !== 'menu')
|
|
240
|
+
]
|
|
241
|
+
: this.enabledPlugins
|
|
204
242
|
tasks.push(copyFrameworkSource({
|
|
205
243
|
destDir: this.dir,
|
|
206
|
-
enabledPlugins:
|
|
244
|
+
enabledPlugins: pluginsToInclude.map(p => p.name),
|
|
207
245
|
linkNodeModules: !this.isExport
|
|
208
246
|
}))
|
|
209
247
|
}
|
|
@@ -233,6 +271,18 @@ class AdaptFrameworkBuild {
|
|
|
233
271
|
.setData(e)
|
|
234
272
|
}
|
|
235
273
|
}
|
|
274
|
+
// Populate prebuilt cache after successful grunt build for preview
|
|
275
|
+
if (this.isPreview && !contentOnly) {
|
|
276
|
+
const cache = new BuildCache(path.join(framework.getConfig('buildDir'), 'prebuilt-cache'))
|
|
277
|
+
const pluginHash = await framework.getPluginHash()
|
|
278
|
+
const theme = this.courseData.config.data._theme
|
|
279
|
+
const menu = this.courseData.config.data._menu
|
|
280
|
+
try {
|
|
281
|
+
await cache.populate(this.buildDir, pluginHash, theme, menu)
|
|
282
|
+
} catch (e) {
|
|
283
|
+
log('warn', 'CACHE', `failed to populate prebuilt cache: ${e.message}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
236
286
|
if (this.compress) {
|
|
237
287
|
this.location = await this.prepareZip()
|
|
238
288
|
} else {
|
|
@@ -275,10 +325,10 @@ class AdaptFrameworkBuild {
|
|
|
275
325
|
* @return {Promise}
|
|
276
326
|
*/
|
|
277
327
|
async loadAssetData () {
|
|
278
|
-
const [assets,
|
|
328
|
+
const [assets, content, tags] = await App.instance.waitForModule('assets', 'content', 'tags')
|
|
279
329
|
|
|
280
|
-
const
|
|
281
|
-
const uniqueAssetIds = new Set(
|
|
330
|
+
const courseContent = await content.find({ _courseId: this.courseId }, { validate: false }, { projection: { _assetIds: 1 } })
|
|
331
|
+
const uniqueAssetIds = new Set(courseContent.flatMap(c => (c._assetIds ?? []).map(id => parseObjectId(id))))
|
|
282
332
|
const usedAssets = await assets.find({ _id: { $in: [...uniqueAssetIds] } })
|
|
283
333
|
|
|
284
334
|
const usedTagIds = new Set(usedAssets.reduce((m, a) => [...m, ...(a.tags ?? [])], []))
|
|
@@ -426,12 +476,35 @@ class AdaptFrameworkBuild {
|
|
|
426
476
|
}))
|
|
427
477
|
}
|
|
428
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Outputs all course data to the required JSON files
|
|
481
|
+
* @return {Promise}
|
|
482
|
+
*/
|
|
483
|
+
async writeContentJson () {
|
|
484
|
+
const data = Object.values(this.courseData)
|
|
485
|
+
if (this.isExport && this.assetData.data.length) {
|
|
486
|
+
this.assetData.data = this.assetData.data.map(d => {
|
|
487
|
+
return {
|
|
488
|
+
title: d.title,
|
|
489
|
+
description: d.description,
|
|
490
|
+
filename: d.path,
|
|
491
|
+
tags: d.tags
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
data.push(this.assetData)
|
|
495
|
+
}
|
|
496
|
+
return Promise.all(data.map(async ({ dir, fileName, data }) => {
|
|
497
|
+
await ensureDir(dir)
|
|
498
|
+
const filepath = path.join(dir, fileName)
|
|
499
|
+
const returnData = await writeJson(filepath, data)
|
|
500
|
+
log('verbose', 'WRITE', filepath)
|
|
501
|
+
return returnData
|
|
502
|
+
}))
|
|
503
|
+
}
|
|
504
|
+
|
|
429
505
|
/**
|
|
430
506
|
* Applies schema defaults to the in-memory course and config data using
|
|
431
507
|
* the jsonschema module. Replicates what grunt's schema-defaults task does.
|
|
432
|
-
*
|
|
433
|
-
* TODO: replace validateWithDefaults workaround with schema.validate(data, { ignoreErrors: true })
|
|
434
|
-
* once migrated to adapt-schemas v3.x (see #184)
|
|
435
508
|
* @return {Promise}
|
|
436
509
|
*/
|
|
437
510
|
async applySchemaDefaults () {
|
|
@@ -442,31 +515,19 @@ class AdaptFrameworkBuild {
|
|
|
442
515
|
const extensionFilter = s => contentplugin.isPluginSchema(s) ? enabledPluginSchemas.includes(s) : true
|
|
443
516
|
const getSchema = name => jsonschema.getSchema(name, { useCache: false, extensionFilter })
|
|
444
517
|
|
|
445
|
-
|
|
446
|
-
* Applies defaults via validate(), catching and ignoring validation errors.
|
|
447
|
-
* The validated+defaulted data is returned from validate() on success, or
|
|
448
|
-
* extracted from the error on failure (validate clones internally).
|
|
449
|
-
*/
|
|
450
|
-
const validateWithDefaults = (schema, data) => {
|
|
451
|
-
try {
|
|
452
|
-
return schema.validate(data, { useDefaults: true, ignoreRequired: true })
|
|
453
|
-
} catch (e) {
|
|
454
|
-
return e.data.data
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
518
|
+
// Apply defaults without running full validation (which rejects ObjectIds etc.)
|
|
458
519
|
const [courseSchema, configSchema] = await Promise.all([
|
|
459
520
|
getSchema('course'),
|
|
460
521
|
getSchema('config')
|
|
461
522
|
])
|
|
462
|
-
|
|
463
|
-
|
|
523
|
+
courseSchema.compiledWithDefaults(this.courseData.course.data)
|
|
524
|
+
configSchema.compiledWithDefaults(this.courseData.config.data)
|
|
464
525
|
|
|
465
526
|
for (const type of ['contentObject', 'article', 'block']) {
|
|
466
527
|
const schemaName = type === 'contentObject' ? 'contentobject' : type
|
|
467
528
|
const schema = await getSchema(schemaName)
|
|
468
529
|
for (const item of this.courseData[type].data) {
|
|
469
|
-
|
|
530
|
+
schema.compiledWithDefaults(item)
|
|
470
531
|
}
|
|
471
532
|
}
|
|
472
533
|
|
|
@@ -476,34 +537,23 @@ class AdaptFrameworkBuild {
|
|
|
476
537
|
if (!componentSchemas[schemaName]) {
|
|
477
538
|
componentSchemas[schemaName] = await getSchema(schemaName)
|
|
478
539
|
}
|
|
479
|
-
|
|
540
|
+
componentSchemas[schemaName].compiledWithDefaults(item)
|
|
480
541
|
}
|
|
481
542
|
}
|
|
482
543
|
|
|
483
544
|
/**
|
|
484
|
-
*
|
|
545
|
+
* Writes the language_data_manifest.js for each language dir.
|
|
546
|
+
* Only needed on cache-hit builds where grunt is skipped.
|
|
485
547
|
* @return {Promise}
|
|
486
548
|
*/
|
|
487
|
-
async
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
tags: d.tags
|
|
496
|
-
}
|
|
497
|
-
})
|
|
498
|
-
data.push(this.assetData)
|
|
499
|
-
}
|
|
500
|
-
return Promise.all(data.map(async ({ dir, fileName, data }) => {
|
|
501
|
-
await ensureDir(dir)
|
|
502
|
-
const filepath = path.join(dir, fileName)
|
|
503
|
-
const returnData = await writeJson(filepath, data)
|
|
504
|
-
log('verbose', 'WRITE', filepath)
|
|
505
|
-
return returnData
|
|
506
|
-
}))
|
|
549
|
+
async writeLanguageManifest () {
|
|
550
|
+
const langDir = this.courseData.course.dir
|
|
551
|
+
const fileNames = Object.values(this.courseData)
|
|
552
|
+
.filter(d => d.dir === langDir)
|
|
553
|
+
.map(d => d.fileName)
|
|
554
|
+
const manifest = generateLanguageManifest(fileNames)
|
|
555
|
+
await ensureDir(langDir)
|
|
556
|
+
await writeJson(path.join(langDir, 'language_data_manifest.js'), manifest)
|
|
507
557
|
}
|
|
508
558
|
|
|
509
559
|
/**
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { App, Hook,
|
|
2
|
-
import { parseObjectId } from 'adapt-authoring-mongodb'
|
|
1
|
+
import { App, Hook, readJson, writeJson } from 'adapt-authoring-core'
|
|
2
|
+
import { isValidObjectId, parseObjectId } from 'adapt-authoring-mongodb'
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
4
|
import { glob } from 'glob'
|
|
5
|
-
import octopus from 'adapt-octopus'
|
|
6
5
|
import path from 'upath'
|
|
7
|
-
import { randomBytes } from 'node:crypto'
|
|
8
6
|
import semver from 'semver'
|
|
9
7
|
import { unzip } from 'zipper'
|
|
10
|
-
import { log, logDir, getImportSummary, getImportContentCounts } from './utils.js'
|
|
8
|
+
import { log, logDir, getImportSummary, getImportContentCounts, readFrameworkPluginVersions, collectMigrationScripts, runContentMigration } from './utils.js'
|
|
11
9
|
|
|
12
10
|
import ComponentTransform from './migrations/component.js'
|
|
13
11
|
import ConfigTransform from './migrations/config.js'
|
|
@@ -15,6 +13,7 @@ import GraphicSrcTransform from './migrations/graphic-src.js'
|
|
|
15
13
|
import NavOrderTransform from './migrations/nav-order.js'
|
|
16
14
|
import ParentIdTransform from './migrations/parent-id.js'
|
|
17
15
|
import RemoveUndefTransform from './migrations/remove-undef.js'
|
|
16
|
+
import VanillaBackgroundStylesTransform from './migrations/vanilla-background-styles.js'
|
|
18
17
|
import StartPageTransform from './migrations/start-page.js'
|
|
19
18
|
import ThemeUndefTransform from './migrations/theme-undef.js'
|
|
20
19
|
|
|
@@ -26,7 +25,8 @@ const ContentMigrations = [
|
|
|
26
25
|
ParentIdTransform,
|
|
27
26
|
RemoveUndefTransform,
|
|
28
27
|
StartPageTransform,
|
|
29
|
-
ThemeUndefTransform
|
|
28
|
+
ThemeUndefTransform,
|
|
29
|
+
VanillaBackgroundStylesTransform
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -175,7 +175,8 @@ class AdaptFrameworkImport {
|
|
|
175
175
|
*/
|
|
176
176
|
this.statusReport = {
|
|
177
177
|
info: [],
|
|
178
|
-
warn: []
|
|
178
|
+
warn: [],
|
|
179
|
+
error: []
|
|
179
180
|
}
|
|
180
181
|
/**
|
|
181
182
|
* Summary information for the import run
|
|
@@ -221,10 +222,9 @@ class AdaptFrameworkImport {
|
|
|
221
222
|
assets,
|
|
222
223
|
content,
|
|
223
224
|
contentplugin,
|
|
224
|
-
courseassets,
|
|
225
225
|
framework,
|
|
226
226
|
jsonschema
|
|
227
|
-
] = await App.instance.waitForModule('assets', 'content', 'contentplugin', '
|
|
227
|
+
] = await App.instance.waitForModule('assets', 'content', 'contentplugin', 'adaptframework', 'jsonschema')
|
|
228
228
|
/**
|
|
229
229
|
* Cached module instance for easy access
|
|
230
230
|
* @type {AssetsModule}
|
|
@@ -240,11 +240,6 @@ class AdaptFrameworkImport {
|
|
|
240
240
|
* @type {ContentPluginModule}
|
|
241
241
|
*/
|
|
242
242
|
this.contentplugin = contentplugin
|
|
243
|
-
/**
|
|
244
|
-
* Cached module instance for easy access
|
|
245
|
-
* @type {CourseAssetsModule}
|
|
246
|
-
*/
|
|
247
|
-
this.courseassets = courseassets
|
|
248
243
|
/**
|
|
249
244
|
* Cached module instance for easy access
|
|
250
245
|
* @type {AdaptFrameworkModule}
|
|
@@ -269,9 +264,8 @@ class AdaptFrameworkImport {
|
|
|
269
264
|
[this.importCourseAssets, importContent],
|
|
270
265
|
[this.importCoursePlugins, isDryRun && importPlugins],
|
|
271
266
|
[this.importCoursePlugins, !isDryRun && importContent],
|
|
272
|
-
[this.loadCourseData,
|
|
267
|
+
[this.loadCourseData, importContent],
|
|
273
268
|
[this.migrateCourseData, !isDryRun && migrateContent],
|
|
274
|
-
[this.loadCourseData, !isDryRun && importContent],
|
|
275
269
|
[this.importCourseData, !isDryRun && importContent],
|
|
276
270
|
[this.generateSummary]
|
|
277
271
|
]
|
|
@@ -344,34 +338,18 @@ class AdaptFrameworkImport {
|
|
|
344
338
|
}
|
|
345
339
|
this.statusReport.info.push({ code: 'MIGRATE_CONTENT', data })
|
|
346
340
|
}
|
|
347
|
-
await this.convertSchemas()
|
|
348
341
|
log('debug', 'preparation tasks completed successfully')
|
|
349
342
|
}
|
|
350
343
|
|
|
351
344
|
/**
|
|
352
|
-
*
|
|
353
|
-
* @return {Promise}
|
|
354
|
-
*/
|
|
355
|
-
async convertSchemas () {
|
|
356
|
-
return octopus.runRecursive({
|
|
357
|
-
cwd: this.path,
|
|
358
|
-
logger: { log: (...args) => log('debug', ...args) }
|
|
359
|
-
})
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Writes the contents of 2-customStyles.less to course.json file. Unfortunately it's necessary to do it this way to ensure it's included in migrations.
|
|
345
|
+
* Reads 2-customStyles.less (if present) and injects its contents as customStyle on the in-memory course, so migrations and the DB write see it. Existing customStyle on the course takes precedence.
|
|
364
346
|
*/
|
|
365
347
|
async patchCustomStyle () {
|
|
366
348
|
const [customStylePath] = await glob('**/2-customStyles.less', { cwd: this.path, absolute: true })
|
|
367
|
-
|
|
368
|
-
if (!customStylePath) {
|
|
369
|
-
return
|
|
370
|
-
}
|
|
349
|
+
if (!customStylePath) return
|
|
371
350
|
try {
|
|
372
351
|
const customStyle = await fs.readFile(customStylePath, 'utf8')
|
|
373
|
-
|
|
374
|
-
await writeJson(courseJsonPath, { customStyle, ...courseJson })
|
|
352
|
+
this.contentJson.course = { customStyle, ...this.contentJson.course }
|
|
375
353
|
log('info', 'patched course customStyle')
|
|
376
354
|
} catch (e) {
|
|
377
355
|
log('warn', 'failed to patch course customStyle', e)
|
|
@@ -379,15 +357,14 @@ class AdaptFrameworkImport {
|
|
|
379
357
|
}
|
|
380
358
|
|
|
381
359
|
/**
|
|
382
|
-
* Ensures _theme exists on the config
|
|
360
|
+
* Ensures _theme exists on the in-memory config
|
|
383
361
|
*/
|
|
384
362
|
async patchThemeName () {
|
|
385
363
|
try {
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
await writeJson(configJsonPath, configJson)
|
|
364
|
+
if (this.contentJson.config?._theme) return
|
|
365
|
+
const _theme = Object.values(this.usedContentPlugins).find(p => p.type === 'theme')?.name
|
|
366
|
+
if (!_theme || !this.contentJson.config) return
|
|
367
|
+
this.contentJson.config._theme = _theme
|
|
391
368
|
log('info', 'patched config _theme')
|
|
392
369
|
} catch (e) {
|
|
393
370
|
log('warn', 'failed to patch config _theme', e)
|
|
@@ -492,47 +469,61 @@ class AdaptFrameworkImport {
|
|
|
492
469
|
}
|
|
493
470
|
|
|
494
471
|
/**
|
|
495
|
-
*
|
|
496
|
-
* @return {Promise}
|
|
497
|
-
*/
|
|
498
|
-
async runGruntMigration (subTask, { outputDir, captureDir, outputFilePath }) {
|
|
499
|
-
const output = await spawn({
|
|
500
|
-
cmd: 'npx',
|
|
501
|
-
args: ['grunt', `migration:${subTask}`, `--outputdir=${outputDir}`, `--capturedir=${captureDir}`],
|
|
502
|
-
cwd: this.frameworkPath ?? this.framework.path
|
|
503
|
-
})
|
|
504
|
-
if (outputFilePath) await fs.writeFile(outputFilePath, output)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Handle migrate course data, installs adapt-migrations/capture data/adds updated scripts/migrates data
|
|
472
|
+
* Migrates course data in-memory using adapt-migrations
|
|
509
473
|
*/
|
|
510
474
|
async migrateCourseData () {
|
|
511
475
|
try {
|
|
512
476
|
await this.patchThemeName()
|
|
513
477
|
await this.patchCustomStyle()
|
|
514
478
|
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
log('debug', 'MIGRATION_ID', migrationId)
|
|
523
|
-
logDir('captureDir', opts.captureDir)
|
|
524
|
-
logDir('outputDir', opts.outputDir)
|
|
479
|
+
const content = this.flattenContentJson()
|
|
480
|
+
const fromPlugins = Object.values(this.usedContentPlugins).map(p => ({
|
|
481
|
+
name: p.name,
|
|
482
|
+
version: p.version
|
|
483
|
+
}))
|
|
484
|
+
const toPlugins = await readFrameworkPluginVersions(this.framework.path)
|
|
485
|
+
const scripts = await collectMigrationScripts(this.framework.path)
|
|
525
486
|
|
|
526
|
-
await
|
|
527
|
-
await this.runGruntMigration('migrate', opts)
|
|
487
|
+
const migrated = await runContentMigration({ content, fromPlugins, toPlugins, scripts })
|
|
528
488
|
|
|
529
|
-
|
|
489
|
+
this.unflattenContentJson(migrated)
|
|
490
|
+
log('info', 'in-memory content migration completed')
|
|
530
491
|
} catch (error) {
|
|
531
492
|
log('error', 'Migration process failed', error)
|
|
532
493
|
throw App.instance.errors.FW_IMPORT_MIGRATION_FAILED.setData({ reason: error.message })
|
|
533
494
|
}
|
|
534
495
|
}
|
|
535
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Flattens this.contentJson into a flat array for adapt-migrations
|
|
499
|
+
* @returns {Array}
|
|
500
|
+
*/
|
|
501
|
+
flattenContentJson () {
|
|
502
|
+
const content = []
|
|
503
|
+
if (this.contentJson.course?._id) content.push(this.contentJson.course)
|
|
504
|
+
if (this.contentJson.config?._id) content.push(this.contentJson.config)
|
|
505
|
+
for (const item of Object.values(this.contentJson.contentObjects)) {
|
|
506
|
+
if (item?._id) content.push(item)
|
|
507
|
+
}
|
|
508
|
+
return content
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Writes migrated content back into the contentJson structure
|
|
513
|
+
* @param {Array} migrated The migrated content array
|
|
514
|
+
*/
|
|
515
|
+
unflattenContentJson (migrated) {
|
|
516
|
+
for (const item of migrated) {
|
|
517
|
+
if (item._type === 'course') {
|
|
518
|
+
this.contentJson.course = item
|
|
519
|
+
} else if (item._type === 'config') {
|
|
520
|
+
this.contentJson.config = item
|
|
521
|
+
} else {
|
|
522
|
+
this.contentJson.contentObjects[item._id] = item
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
536
527
|
/**
|
|
537
528
|
* Imports any specified tags
|
|
538
529
|
* @return {Promise}
|
|
@@ -586,13 +577,15 @@ class AdaptFrameworkImport {
|
|
|
586
577
|
if (this.settings.isDryRun) {
|
|
587
578
|
return
|
|
588
579
|
}
|
|
580
|
+
const stats = await fs.stat(filepath)
|
|
589
581
|
try {
|
|
590
582
|
const asset = await this.assets.insert({
|
|
591
583
|
...data,
|
|
592
584
|
createdBy: this.userId,
|
|
593
585
|
file: {
|
|
594
586
|
filepath,
|
|
595
|
-
originalFilename: filepath
|
|
587
|
+
originalFilename: filepath,
|
|
588
|
+
size: stats.size
|
|
596
589
|
},
|
|
597
590
|
tags: data.tags
|
|
598
591
|
})
|
|
@@ -600,7 +593,13 @@ class AdaptFrameworkImport {
|
|
|
600
593
|
const resolved = path.relative(`${this.coursePath}/..`, filepath)
|
|
601
594
|
this.assetMap[resolved] = asset._id.toString()
|
|
602
595
|
} catch (e) {
|
|
603
|
-
|
|
596
|
+
if (e.code === 'DUPLICATE_ASSET') {
|
|
597
|
+
const resolved = path.relative(`${this.coursePath}/..`, filepath)
|
|
598
|
+
this.assetMap[resolved] = e.data.assetId
|
|
599
|
+
} else {
|
|
600
|
+
log('error', `asset import failed for '${filepath}'`, e)
|
|
601
|
+
this.statusReport.warn.push({ code: 'ASSET_IMPORT_FAILED', data: { filepath, reason: e?.message ?? String(e) } })
|
|
602
|
+
}
|
|
604
603
|
}
|
|
605
604
|
imagesImported++
|
|
606
605
|
}))
|
|
@@ -738,8 +737,9 @@ class AdaptFrameworkImport {
|
|
|
738
737
|
try {
|
|
739
738
|
const course = await this.importContentObject({ ...this.contentJson.course, tags: this.tags })
|
|
740
739
|
/* config */ await this.importContentObject(this.contentJson.config)
|
|
741
|
-
// we need to run an update with the same data to make sure all extension schema settings are applied
|
|
742
|
-
|
|
740
|
+
// we need to run an update with the same data to make sure all extension schema settings are applied;
|
|
741
|
+
// ignoreRequired because some plugins declare top-level required properties with no default that Ajv can't materialise (e.g. adapt-contrib-glossary)
|
|
742
|
+
await this.importContentObject({ ...this.contentJson.course, _id: course._id }, { isUpdate: true, ignoreRequired: true })
|
|
743
743
|
} catch (e) {
|
|
744
744
|
throw App.instance.errors.FW_IMPORT_CONTENT_FAILED.setData({ errors: [formatError(e)] })
|
|
745
745
|
}
|
|
@@ -751,8 +751,8 @@ class AdaptFrameworkImport {
|
|
|
751
751
|
try {
|
|
752
752
|
const itemJson = this.contentJson.contentObjects[_id]
|
|
753
753
|
await this.importContentObject({
|
|
754
|
-
|
|
755
|
-
|
|
754
|
+
...itemJson,
|
|
755
|
+
_sortOrder: hierarchy[itemJson._parentId].indexOf(_id) + 1 // trust the hierarchy: per-insert updateSortOrder is disabled, so we can't rely on bad export values being normalised later
|
|
756
756
|
})
|
|
757
757
|
} catch (e) {
|
|
758
758
|
errors.push(formatError(e))
|
|
@@ -760,6 +760,8 @@ class AdaptFrameworkImport {
|
|
|
760
760
|
}
|
|
761
761
|
}
|
|
762
762
|
if (errors.length) throw App.instance.errors.FW_IMPORT_CONTENT_FAILED.setData({ errors })
|
|
763
|
+
// single-pass sweep now all content is in place; per-insert sweep was disabled to avoid O(n²) work
|
|
764
|
+
await this.content.updateEnabledPlugins({ _courseId: this.idMap.course }, { forceUpdate: true })
|
|
763
765
|
log('debug', 'imported course data successfully')
|
|
764
766
|
}
|
|
765
767
|
|
|
@@ -798,6 +800,7 @@ class AdaptFrameworkImport {
|
|
|
798
800
|
let insertData = await this.transformData({
|
|
799
801
|
...data,
|
|
800
802
|
_id: undefined,
|
|
803
|
+
_assetIds: undefined, // recompute from resolved asset references; export ships paths, not ObjectIds
|
|
801
804
|
_courseId: this.idMap.course,
|
|
802
805
|
createdBy: this.userId
|
|
803
806
|
})
|
|
@@ -810,7 +813,7 @@ class AdaptFrameworkImport {
|
|
|
810
813
|
}
|
|
811
814
|
insertData = schema.sanitise(insertData)
|
|
812
815
|
let doc
|
|
813
|
-
const opts = { schemaName, validate: true, useCache: false }
|
|
816
|
+
const opts = { schemaName, validate: true, useCache: false, updateEnabledPlugins: false, updateSortOrder: false, ignoreRequired: options.ignoreRequired }
|
|
814
817
|
if (options.isUpdate) {
|
|
815
818
|
doc = await this.content.update({ _id: data._id }, insertData, opts)
|
|
816
819
|
} else {
|
|
@@ -842,7 +845,13 @@ class AdaptFrameworkImport {
|
|
|
842
845
|
schema.walk(data, field =>
|
|
843
846
|
field?._backboneForms?.type === 'Asset' || field?._backboneForms === 'Asset'
|
|
844
847
|
).forEach(({ data: parent, key, value }) => {
|
|
845
|
-
|
|
848
|
+
if (!value) return delete parent[key]
|
|
849
|
+
const mapped = this.assetMap[value]
|
|
850
|
+
if (mapped) return (parent[key] = mapped)
|
|
851
|
+
if (isValidObjectId(value)) return (parent[key] = value)
|
|
852
|
+
log('warn', `unable to resolve asset reference '${value}' — dropping field`)
|
|
853
|
+
this.statusReport.warn.push({ code: 'UNRESOLVED_ASSET_REF', data: { path: value } })
|
|
854
|
+
delete parent[key]
|
|
846
855
|
})
|
|
847
856
|
}
|
|
848
857
|
|
|
@@ -914,9 +923,7 @@ class AdaptFrameworkImport {
|
|
|
914
923
|
const _courseId = parseObjectId(this.idMap[this.contentJson.course._id])
|
|
915
924
|
tasks.push(
|
|
916
925
|
this.content.deleteMany({ _courseId })
|
|
917
|
-
.catch(e => log('warn', 'failed to delete course content', e))
|
|
918
|
-
this.courseassets.deleteMany({ courseId: _courseId })
|
|
919
|
-
.catch(e => log('warn', 'failed to delete course assets', e))
|
|
926
|
+
.catch(e => log('warn', 'failed to delete course content', e))
|
|
920
927
|
)
|
|
921
928
|
} catch (e) {} // courseId not available, no content to roll back
|
|
922
929
|
}
|