adapt-authoring-adaptframework 2.5.6 → 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.
@@ -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
@@ -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: this.enabledPlugins.map(p => p.name),
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, courseassets, tags] = await App.instance.waitForModule('assets', 'courseassets', 'tags')
328
+ const [assets, content, tags] = await App.instance.waitForModule('assets', 'content', 'tags')
279
329
 
280
- const caRecs = await courseassets.find({ courseId: this.courseId })
281
- const uniqueAssetIds = new Set(caRecs.map(c => parseObjectId(c.assetId)))
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
- Object.assign(this.courseData.course.data, validateWithDefaults(courseSchema, this.courseData.course.data))
463
- Object.assign(this.courseData.config.data, validateWithDefaults(configSchema, this.courseData.config.data))
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
- Object.assign(item, validateWithDefaults(schema, item))
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
- Object.assign(item, validateWithDefaults(componentSchemas[schemaName], item))
540
+ componentSchemas[schemaName].compiledWithDefaults(item)
480
541
  }
481
542
  }
482
543
 
483
544
  /**
484
- * Outputs all course data to the required JSON files
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 writeContentJson () {
488
- const data = Object.values(this.courseData)
489
- if (this.isExport && this.assetData.data.length) {
490
- this.assetData.data = this.assetData.data.map(d => {
491
- return {
492
- title: d.title,
493
- description: d.description,
494
- filename: d.path,
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
  /**
@@ -524,7 +574,7 @@ class AdaptFrameworkBuild {
524
574
  async recordBuildAttempt () {
525
575
  const [framework, jsonschema, mongodb] = await App.instance.waitForModule('adaptframework', 'jsonschema', 'mongodb')
526
576
  const schema = await jsonschema.getSchema('adaptbuild')
527
- const validatedData = await schema.validate({
577
+ const validatedData = schema.validate({
528
578
  action: this.action,
529
579
  courseId: this.courseId,
530
580
  location: this.location,
@@ -1,13 +1,11 @@
1
- import { App, Hook, spawn, readJson, writeJson } from 'adapt-authoring-core'
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', 'courseassets', 'adaptframework', 'jsonschema')
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, isDryRun && importContent],
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
- * Converts all properties.schema files to a valid JSON schema format
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
- const courseJsonPath = `${this.langPath}/course.json`
368
- if (!customStylePath) {
369
- return
370
- }
349
+ if (!customStylePath) return
371
350
  try {
372
351
  const customStyle = await fs.readFile(customStylePath, 'utf8')
373
- const courseJson = await readJson(courseJsonPath)
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
- const configJsonPath = `${this.coursePath}/config.json`
387
- const configJson = await readJson(configJsonPath)
388
- if (configJson._theme) return
389
- configJson._theme = Object.values(this.usedContentPlugins).find(p => p.type === 'theme').name
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
- * Run grunt task
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 migrationId = `${this.userId}-${randomBytes(4).toString('hex')}`
516
-
517
- const opts = {
518
- outputDir: path.relative(this.framework.path, path.resolve(this.coursePath, '..')),
519
- captureDir: path.join(`./${migrationId}-migrations`),
520
- outputFilePath: path.join(this.framework.path, 'migrations', `${migrationId}.txt`)
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 this.runGruntMigration('capture', opts)
527
- await this.runGruntMigration('migrate', opts)
487
+ const migrated = await runContentMigration({ content, fromPlugins, toPlugins, scripts })
528
488
 
529
- await fs.rm(path.join(this.framework.path, opts.captureDir), { recursive: true })
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
- this.statusReport.warn.push({ code: 'ASSET_IMPORT_FAILED', data: { filepath } })
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
- await this.importContentObject({ ...this.contentJson.course, _id: course._id }, { isUpdate: true })
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
- _sortOrder: hierarchy[itemJson._parentId].indexOf(_id) + 1,
755
- ...itemJson // note that JSON sort order will override the deduced one
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
  })
@@ -808,9 +811,9 @@ class AdaptFrameworkImport {
808
811
  } catch (e) {
809
812
  log('error', `failed to extract asset data for attribute '${e.attribute}' of schema '${schemaName}', ${e}`)
810
813
  }
811
- insertData = await schema.sanitise(insertData)
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
- value ? parent[key] = this.assetMap[value] ?? value : delete parent[key]
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
  }