adapt-authoring-adaptframework 2.6.0 → 3.0.1

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
  /**
@@ -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
  ]
@@ -310,9 +304,13 @@ class AdaptFrameworkImport {
310
304
  }
311
305
  // find and store the course data path
312
306
  const courseDirs = await glob(`${this.path}/*/course`)
307
+ if (courseDirs.length === 0) {
308
+ this.framework.log('error', 'NO_COURSE_DIR', this.path)
309
+ throw App.instance.errors.FW_IMPORT_INVALID_COURSE.setData({ reason: 'no source course directory found in archive; expected a course folder nested one level deep' })
310
+ }
313
311
  if (courseDirs.length > 1) {
314
312
  this.framework.log('error', 'MULTIPLE_COURSE_DIRS', courseDirs)
315
- throw App.instance.errors.FW_IMPORT_INVALID_COURSE
313
+ throw App.instance.errors.FW_IMPORT_INVALID_COURSE.setData({ reason: 'multiple course directories found in archive; expected exactly one' })
316
314
  }
317
315
  this.coursePath = courseDirs[0]
318
316
  try {
@@ -344,34 +342,18 @@ class AdaptFrameworkImport {
344
342
  }
345
343
  this.statusReport.info.push({ code: 'MIGRATE_CONTENT', data })
346
344
  }
347
- await this.convertSchemas()
348
345
  log('debug', 'preparation tasks completed successfully')
349
346
  }
350
347
 
351
348
  /**
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.
349
+ * 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
350
  */
365
351
  async patchCustomStyle () {
366
352
  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
- }
353
+ if (!customStylePath) return
371
354
  try {
372
355
  const customStyle = await fs.readFile(customStylePath, 'utf8')
373
- const courseJson = await readJson(courseJsonPath)
374
- await writeJson(courseJsonPath, { customStyle, ...courseJson })
356
+ this.contentJson.course = { customStyle, ...this.contentJson.course }
375
357
  log('info', 'patched course customStyle')
376
358
  } catch (e) {
377
359
  log('warn', 'failed to patch course customStyle', e)
@@ -379,15 +361,14 @@ class AdaptFrameworkImport {
379
361
  }
380
362
 
381
363
  /**
382
- * Ensures _theme exists on the config
364
+ * Ensures _theme exists on the in-memory config
383
365
  */
384
366
  async patchThemeName () {
385
367
  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)
368
+ if (this.contentJson.config?._theme) return
369
+ const _theme = Object.values(this.usedContentPlugins).find(p => p.type === 'theme')?.name
370
+ if (!_theme || !this.contentJson.config) return
371
+ this.contentJson.config._theme = _theme
391
372
  log('info', 'patched config _theme')
392
373
  } catch (e) {
393
374
  log('warn', 'failed to patch config _theme', e)
@@ -492,47 +473,61 @@ class AdaptFrameworkImport {
492
473
  }
493
474
 
494
475
  /**
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
476
+ * Migrates course data in-memory using adapt-migrations
509
477
  */
510
478
  async migrateCourseData () {
511
479
  try {
512
480
  await this.patchThemeName()
513
481
  await this.patchCustomStyle()
514
482
 
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)
483
+ const content = this.flattenContentJson()
484
+ const fromPlugins = Object.values(this.usedContentPlugins).map(p => ({
485
+ name: p.name,
486
+ version: p.version
487
+ }))
488
+ const toPlugins = await readFrameworkPluginVersions(this.framework.path)
489
+ const scripts = await collectMigrationScripts(this.framework.path)
525
490
 
526
- await this.runGruntMigration('capture', opts)
527
- await this.runGruntMigration('migrate', opts)
491
+ const migrated = await runContentMigration({ content, fromPlugins, toPlugins, scripts })
528
492
 
529
- await fs.rm(path.join(this.framework.path, opts.captureDir), { recursive: true })
493
+ this.unflattenContentJson(migrated)
494
+ log('info', 'in-memory content migration completed')
530
495
  } catch (error) {
531
496
  log('error', 'Migration process failed', error)
532
497
  throw App.instance.errors.FW_IMPORT_MIGRATION_FAILED.setData({ reason: error.message })
533
498
  }
534
499
  }
535
500
 
501
+ /**
502
+ * Flattens this.contentJson into a flat array for adapt-migrations
503
+ * @returns {Array}
504
+ */
505
+ flattenContentJson () {
506
+ const content = []
507
+ if (this.contentJson.course?._id) content.push(this.contentJson.course)
508
+ if (this.contentJson.config?._id) content.push(this.contentJson.config)
509
+ for (const item of Object.values(this.contentJson.contentObjects)) {
510
+ if (item?._id) content.push(item)
511
+ }
512
+ return content
513
+ }
514
+
515
+ /**
516
+ * Writes migrated content back into the contentJson structure
517
+ * @param {Array} migrated The migrated content array
518
+ */
519
+ unflattenContentJson (migrated) {
520
+ for (const item of migrated) {
521
+ if (item._type === 'course') {
522
+ this.contentJson.course = item
523
+ } else if (item._type === 'config') {
524
+ this.contentJson.config = item
525
+ } else {
526
+ this.contentJson.contentObjects[item._id] = item
527
+ }
528
+ }
529
+ }
530
+
536
531
  /**
537
532
  * Imports any specified tags
538
533
  * @return {Promise}
@@ -586,13 +581,15 @@ class AdaptFrameworkImport {
586
581
  if (this.settings.isDryRun) {
587
582
  return
588
583
  }
584
+ const stats = await fs.stat(filepath)
589
585
  try {
590
586
  const asset = await this.assets.insert({
591
587
  ...data,
592
588
  createdBy: this.userId,
593
589
  file: {
594
590
  filepath,
595
- originalFilename: filepath
591
+ originalFilename: filepath,
592
+ size: stats.size
596
593
  },
597
594
  tags: data.tags
598
595
  })
@@ -600,7 +597,13 @@ class AdaptFrameworkImport {
600
597
  const resolved = path.relative(`${this.coursePath}/..`, filepath)
601
598
  this.assetMap[resolved] = asset._id.toString()
602
599
  } catch (e) {
603
- this.statusReport.warn.push({ code: 'ASSET_IMPORT_FAILED', data: { filepath } })
600
+ if (e.code === 'DUPLICATE_ASSET') {
601
+ const resolved = path.relative(`${this.coursePath}/..`, filepath)
602
+ this.assetMap[resolved] = e.data.assetId
603
+ } else {
604
+ log('error', `asset import failed for '${filepath}'`, e)
605
+ this.statusReport.warn.push({ code: 'ASSET_IMPORT_FAILED', data: { filepath, reason: e?.message ?? String(e) } })
606
+ }
604
607
  }
605
608
  imagesImported++
606
609
  }))
@@ -738,8 +741,9 @@ class AdaptFrameworkImport {
738
741
  try {
739
742
  const course = await this.importContentObject({ ...this.contentJson.course, tags: this.tags })
740
743
  /* 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 })
744
+ // we need to run an update with the same data to make sure all extension schema settings are applied;
745
+ // ignoreRequired because some plugins declare top-level required properties with no default that Ajv can't materialise (e.g. adapt-contrib-glossary)
746
+ await this.importContentObject({ ...this.contentJson.course, _id: course._id }, { isUpdate: true, ignoreRequired: true })
743
747
  } catch (e) {
744
748
  throw App.instance.errors.FW_IMPORT_CONTENT_FAILED.setData({ errors: [formatError(e)] })
745
749
  }
@@ -751,8 +755,8 @@ class AdaptFrameworkImport {
751
755
  try {
752
756
  const itemJson = this.contentJson.contentObjects[_id]
753
757
  await this.importContentObject({
754
- _sortOrder: hierarchy[itemJson._parentId].indexOf(_id) + 1,
755
- ...itemJson // note that JSON sort order will override the deduced one
758
+ ...itemJson,
759
+ _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
760
  })
757
761
  } catch (e) {
758
762
  errors.push(formatError(e))
@@ -760,6 +764,8 @@ class AdaptFrameworkImport {
760
764
  }
761
765
  }
762
766
  if (errors.length) throw App.instance.errors.FW_IMPORT_CONTENT_FAILED.setData({ errors })
767
+ // single-pass sweep now all content is in place; per-insert sweep was disabled to avoid O(n²) work
768
+ await this.content.updateEnabledPlugins({ _courseId: this.idMap.course }, { forceUpdate: true })
763
769
  log('debug', 'imported course data successfully')
764
770
  }
765
771
 
@@ -798,6 +804,7 @@ class AdaptFrameworkImport {
798
804
  let insertData = await this.transformData({
799
805
  ...data,
800
806
  _id: undefined,
807
+ _assetIds: undefined, // recompute from resolved asset references; export ships paths, not ObjectIds
801
808
  _courseId: this.idMap.course,
802
809
  createdBy: this.userId
803
810
  })
@@ -810,7 +817,7 @@ class AdaptFrameworkImport {
810
817
  }
811
818
  insertData = schema.sanitise(insertData)
812
819
  let doc
813
- const opts = { schemaName, validate: true, useCache: false }
820
+ const opts = { schemaName, validate: true, useCache: false, updateEnabledPlugins: false, updateSortOrder: false, ignoreRequired: options.ignoreRequired }
814
821
  if (options.isUpdate) {
815
822
  doc = await this.content.update({ _id: data._id }, insertData, opts)
816
823
  } else {
@@ -842,7 +849,13 @@ class AdaptFrameworkImport {
842
849
  schema.walk(data, field =>
843
850
  field?._backboneForms?.type === 'Asset' || field?._backboneForms === 'Asset'
844
851
  ).forEach(({ data: parent, key, value }) => {
845
- value ? parent[key] = this.assetMap[value] ?? value : delete parent[key]
852
+ if (!value) return delete parent[key]
853
+ const mapped = this.assetMap[value]
854
+ if (mapped) return (parent[key] = mapped)
855
+ if (isValidObjectId(value)) return (parent[key] = value)
856
+ log('warn', `unable to resolve asset reference '${value}' — dropping field`)
857
+ this.statusReport.warn.push({ code: 'UNRESOLVED_ASSET_REF', data: { path: value } })
858
+ delete parent[key]
846
859
  })
847
860
  }
848
861
 
@@ -914,9 +927,7 @@ class AdaptFrameworkImport {
914
927
  const _courseId = parseObjectId(this.idMap[this.contentJson.course._id])
915
928
  tasks.push(
916
929
  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))
930
+ .catch(e => log('warn', 'failed to delete course content', e))
920
931
  )
921
932
  } catch (e) {} // courseId not available, no content to roll back
922
933
  }